diff --git a/messages/de.json b/messages/de.json index c2c58573e..934d38f27 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3378,6 +3378,29 @@ "withingsDisconnectDescription": "Die Verbindung zu Withings wird getrennt. Bereits synchronisierte Daten bleiben erhalten.", "withingsConnect": "Mit Withings verbinden", "withingsNoCredentials": "Bitte zuerst die API-Zugangsdaten oben eingeben, um Withings zu verbinden.", + "whoop": "WHOOP", + "whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts zu synchronisieren.", + "whoopCredentials": "API-Zugangsdaten", + "whoopCredentialsHelp": "Registriere deine eigene WHOOP-Entwickler-App und füge hier Client ID und Client Secret ein.", + "whoopClientId": "Client ID", + "whoopClientSecret": "Client Secret", + "whoopCredentialsSaved": "Zugangsdaten gespeichert", + "whoopCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", + "whoopCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen", + "whoopSaveCredentials": "Zugangsdaten speichern", + "whoopSync": "Jetzt synchronisieren", + "whoopFullSync": "Alle Daten synchronisieren", + "whoopFullSyncTitle": "Vollständige Synchronisierung?", + "whoopFullSyncDescription": "Der gesamte verfügbare WHOOP-Verlauf wird vollständig synchronisiert. Je nach Verlauf kann das einige Zeit dauern.", + "whoopSyncResult": "{count} Messwerte synchronisiert", + "whoopSyncFailed": "Synchronisierung fehlgeschlagen", + "whoopSynchronize": "Synchronisieren", + "whoopDisconnect": "Trennen", + "whoopDisconnectTitle": "WHOOP trennen?", + "whoopDisconnectDescription": "Die Verbindung zu WHOOP wird getrennt. Bereits synchronisierte Daten bleiben erhalten.", + "whoopConnect": "Mit WHOOP verbinden", + "whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.", + "whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…", "integrations": { "withings": { "reconnect": { diff --git a/messages/en.json b/messages/en.json index ed168d31f..44ef201c8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3378,6 +3378,29 @@ "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", "withingsConnect": "Connect with Withings", "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "whoop": "WHOOP", + "whoopDescription": "Connect your WHOOP strap to sync recovery, sleep, strain and workouts.", + "whoopCredentials": "API Credentials", + "whoopCredentialsHelp": "Register your own WHOOP developer app and paste its Client ID and Client Secret here.", + "whoopClientId": "Client ID", + "whoopClientSecret": "Client Secret", + "whoopCredentialsSaved": "Credentials saved", + "whoopCredentialsSavedPlaceholder": "Saved — enter new to replace", + "whoopCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", + "whoopSaveCredentials": "Save credentials", + "whoopSync": "Sync now", + "whoopFullSync": "Sync all data", + "whoopFullSyncTitle": "Full synchronization?", + "whoopFullSyncDescription": "All available WHOOP history will be fully synchronized. This may take some time depending on your history.", + "whoopSyncResult": "{count} measurements synchronized", + "whoopSyncFailed": "Sync failed", + "whoopSynchronize": "Synchronize", + "whoopDisconnect": "Disconnect", + "whoopDisconnectTitle": "Disconnect WHOOP?", + "whoopDisconnectDescription": "The connection to WHOOP will be disconnected. Previously synced data will be preserved.", + "whoopConnect": "Connect with WHOOP", + "whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.", + "whoopBackfillInProgress": "Importing your WHOOP history in the background…", "integrations": { "withings": { "reconnect": { diff --git a/messages/es.json b/messages/es.json index 92cef9481..6b5c968c8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3378,6 +3378,29 @@ "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", "withingsConnect": "Connect with Withings", "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "whoop": "WHOOP", + "whoopDescription": "Conecta tu banda WHOOP para sincronizar recuperación, sueño, esfuerzo y entrenamientos.", + "whoopCredentials": "Credenciales de API", + "whoopCredentialsHelp": "Registra tu propia app de desarrollador de WHOOP y pega aquí su Client ID y Client Secret.", + "whoopClientId": "Client ID", + "whoopClientSecret": "Client Secret", + "whoopCredentialsSaved": "Credenciales guardadas", + "whoopCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar", + "whoopCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar", + "whoopSaveCredentials": "Guardar credenciales", + "whoopSync": "Sincronizar ahora", + "whoopFullSync": "Sincronizar todos los datos", + "whoopFullSyncTitle": "¿Sincronización completa?", + "whoopFullSyncDescription": "Se sincronizará todo el historial de WHOOP disponible. Esto puede tardar según tu historial.", + "whoopSyncResult": "{count} mediciones sincronizadas", + "whoopSyncFailed": "Error de sincronización", + "whoopSynchronize": "Sincronizar", + "whoopDisconnect": "Desconectar", + "whoopDisconnectTitle": "¿Desconectar WHOOP?", + "whoopDisconnectDescription": "Se desconectará la conexión con WHOOP. Los datos ya sincronizados se conservarán.", + "whoopConnect": "Conectar con WHOOP", + "whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.", + "whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…", "integrations": { "withings": { "reconnect": { diff --git a/messages/fr.json b/messages/fr.json index 398c018c2..2466d41fb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3378,6 +3378,29 @@ "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", "withingsConnect": "Connect with Withings", "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "whoop": "WHOOP", + "whoopDescription": "Connectez votre bracelet WHOOP pour synchroniser récupération, sommeil, effort et entraînements.", + "whoopCredentials": "Identifiants API", + "whoopCredentialsHelp": "Enregistrez votre propre application développeur WHOOP et collez ici son Client ID et son Client Secret.", + "whoopClientId": "Client ID", + "whoopClientSecret": "Client Secret", + "whoopCredentialsSaved": "Identifiants enregistrés", + "whoopCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer", + "whoopCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer", + "whoopSaveCredentials": "Enregistrer les identifiants", + "whoopSync": "Synchroniser", + "whoopFullSync": "Synchroniser toutes les données", + "whoopFullSyncTitle": "Synchronisation complète ?", + "whoopFullSyncDescription": "Tout l'historique WHOOP disponible sera entièrement synchronisé. Cela peut prendre du temps selon votre historique.", + "whoopSyncResult": "{count} mesures synchronisées", + "whoopSyncFailed": "Échec de la synchronisation", + "whoopSynchronize": "Synchroniser", + "whoopDisconnect": "Déconnecter", + "whoopDisconnectTitle": "Déconnecter WHOOP ?", + "whoopDisconnectDescription": "La connexion à WHOOP sera déconnectée. Les données déjà synchronisées seront conservées.", + "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…", "integrations": { "withings": { "reconnect": { diff --git a/messages/it.json b/messages/it.json index c0d4ac0b1..98b31006c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3378,6 +3378,29 @@ "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", "withingsConnect": "Connect with Withings", "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "whoop": "WHOOP", + "whoopDescription": "Collega la tua fascia WHOOP per sincronizzare recupero, sonno, sforzo e allenamenti.", + "whoopCredentials": "Credenziali API", + "whoopCredentialsHelp": "Registra la tua app sviluppatore WHOOP e incolla qui il suo Client ID e Client Secret.", + "whoopClientId": "Client ID", + "whoopClientSecret": "Client Secret", + "whoopCredentialsSaved": "Credenziali salvate", + "whoopCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire", + "whoopCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire", + "whoopSaveCredentials": "Salva credenziali", + "whoopSync": "Sincronizza ora", + "whoopFullSync": "Sincronizza tutti i dati", + "whoopFullSyncTitle": "Sincronizzazione completa?", + "whoopFullSyncDescription": "Verrà sincronizzato tutto lo storico WHOOP disponibile. Potrebbe richiedere tempo a seconda del tuo storico.", + "whoopSyncResult": "{count} misurazioni sincronizzate", + "whoopSyncFailed": "Sincronizzazione non riuscita", + "whoopSynchronize": "Sincronizza", + "whoopDisconnect": "Disconnetti", + "whoopDisconnectTitle": "Disconnettere WHOOP?", + "whoopDisconnectDescription": "La connessione a WHOOP verrà disconnessa. I dati già sincronizzati saranno conservati.", + "whoopConnect": "Connetti con WHOOP", + "whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.", + "whoopBackfillInProgress": "Importazione dello storico WHOOP in background…", "integrations": { "withings": { "reconnect": { diff --git a/messages/pl.json b/messages/pl.json index 7e461159e..c66fd9aa1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3378,6 +3378,29 @@ "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", "withingsConnect": "Connect with Withings", "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "whoop": "WHOOP", + "whoopDescription": "Połącz opaskę WHOOP, aby synchronizować regenerację, sen, obciążenie i treningi.", + "whoopCredentials": "Dane API", + "whoopCredentialsHelp": "Zarejestruj własną aplikację deweloperską WHOOP i wklej tutaj jej Client ID oraz Client Secret.", + "whoopClientId": "Client ID", + "whoopClientSecret": "Client Secret", + "whoopCredentialsSaved": "Dane zapisane", + "whoopCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić", + "whoopCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić", + "whoopSaveCredentials": "Zapisz dane", + "whoopSync": "Synchronizuj teraz", + "whoopFullSync": "Synchronizuj wszystkie dane", + "whoopFullSyncTitle": "Pełna synchronizacja?", + "whoopFullSyncDescription": "Cała dostępna historia WHOOP zostanie w pełni zsynchronizowana. Może to chwilę potrwać w zależności od historii.", + "whoopSyncResult": "Zsynchronizowano {count} pomiarów", + "whoopSyncFailed": "Synchronizacja nie powiodła się", + "whoopSynchronize": "Synchronizuj", + "whoopDisconnect": "Odłącz", + "whoopDisconnectTitle": "Odłączyć WHOOP?", + "whoopDisconnectDescription": "Połączenie z WHOOP zostanie zakończone. Wcześniej zsynchronizowane dane zostaną zachowane.", + "whoopConnect": "Połącz z WHOOP", + "whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.", + "whoopBackfillInProgress": "Importowanie historii WHOOP w tle…", "integrations": { "withings": { "reconnect": { diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 4365f0d9f..dee8ec44b 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -29,15 +29,18 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "integrations.status" } }); - const [withingsStatus, moodLogStatus, dbUser, withingsConn] = + 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, @@ -52,6 +55,14 @@ export const GET = apiHandler(async () => { createdAt: true, }, }), + prisma.whoopConnection.findUnique({ + where: { userId: user.id }, + select: { + tokenExpiresAt: true, + lastSyncedAt: true, + createdAt: true, + }, + }), ]); const now = Date.now(); @@ -82,6 +93,19 @@ export const GET = apiHandler(async () => { enabled: dbUser?.moodLogEnabled ?? false, legacyLastSyncedAt: dbUser?.moodLogLastSyncedAt?.toISOString() ?? null, } satisfies IntegrationViewModel & MoodLogExtras, + { + ...whoopStatus, + configured: + !!dbUser?.whoopClientIdEncrypted && + !!dbUser?.whoopClientSecretEncrypted, + connected: !!whoopConn, + connectedAt: whoopConn?.createdAt?.toISOString() ?? null, + legacyLastSyncedAt: whoopConn?.lastSyncedAt?.toISOString() ?? null, + tokenExpiresAt: whoopConn?.tokenExpiresAt?.toISOString() ?? null, + tokenExpired: whoopConn + ? whoopConn.tokenExpiresAt.getTime() <= now + : null, + } satisfies IntegrationViewModel & WhoopExtras, ], }); }); @@ -108,3 +132,12 @@ interface MoodLogExtras { enabled: boolean; legacyLastSyncedAt: string | null; } + +interface WhoopExtras { + configured: boolean; + connected: boolean; + connectedAt: string | null; + legacyLastSyncedAt: string | null; + tokenExpiresAt: string | null; + tokenExpired: boolean | null; +} diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts new file mode 100644 index 000000000..0816df18c --- /dev/null +++ b/src/app/api/whoop/credentials/__tests__/route.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + whoopConnection: { delete: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), + safeJson: async (req: NextRequest) => { + try { + return { data: await req.json(), error: null }; + } catch { + return { data: null, error: { status: 400 } }; + } + }, +})); + +import { GET, PUT, DELETE } from "../route"; +import { prisma } from "@/lib/db"; + +const userFind = prisma.user.findUnique as ReturnType; +const userUpdate = prisma.user.update as ReturnType; + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/whoop/credentials", { + method: "PUT", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("/api/whoop/credentials", () => { + beforeEach(() => vi.clearAllMocks()); + + it("GET reports hasCredentials false when none stored", async () => { + userFind.mockResolvedValue(null); + const res = (await (GET as unknown as () => Promise<{ data: unknown }>)()) + .data as { hasCredentials: boolean }; + expect(res.hasCredentials).toBe(false); + }); + + it("PUT 422s on missing fields", async () => { + const res = (await ( + PUT as unknown as (r: NextRequest) => Promise<{ status: number }> + )(req({ clientId: "" }))) as { status: number }; + expect(res.status).toBe(422); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("PUT encrypts and stores valid credentials", async () => { + userUpdate.mockResolvedValue({}); + const res = (await ( + PUT as unknown as (r: NextRequest) => Promise<{ data: unknown }> + )(req({ clientId: "id", clientSecret: "secret" }))) as { data: unknown }; + expect((res.data as { updated: boolean }).updated).toBe(true); + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { + whoopClientIdEncrypted: "enc:id", + whoopClientSecretEncrypted: "enc:secret", + }, + }); + }); + + it("DELETE clears credentials and connection", async () => { + userUpdate.mockResolvedValue({}); + (prisma.whoopConnection.delete as ReturnType).mockResolvedValue( + {}, + ); + const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)()) + .data as { deleted: boolean }; + expect(res.deleted).toBe(true); + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { + whoopClientIdEncrypted: null, + whoopClientSecretEncrypted: null, + }, + }); + }); +}); diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts new file mode 100644 index 000000000..645287c2f --- /dev/null +++ b/src/app/api/whoop/credentials/route.ts @@ -0,0 +1,77 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { encrypt } from "@/lib/crypto"; +import { whoopCredentialsSchema } from "@/lib/validations/whoop"; +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +/** + * Check whether the user has WHOOP BYO-key credentials stored (v1.11.0). + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "whoop.credentials.get" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + whoopClientIdEncrypted: true, + whoopClientSecretEncrypted: true, + }, + }); + + return apiSuccess({ + hasCredentials: + !!dbUser?.whoopClientIdEncrypted && !!dbUser?.whoopClientSecretEncrypted, + }); +}); + +/** + * Save WHOOP API credentials (encrypted at rest). + */ +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "whoop.credentials.update" } }); + + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const result = z.safeParse(whoopCredentialsSchema, body); + if (!result.success) { + return apiError("Client ID and Client Secret are required", 422); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { + whoopClientIdEncrypted: encrypt(result.data.clientId), + whoopClientSecretEncrypted: encrypt(result.data.clientSecret), + }, + }); + + return apiSuccess({ updated: true }); +}); + +/** + * Delete WHOOP credentials and the active connection. + */ +export const DELETE = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "whoop.credentials.delete" } }); + + await prisma.whoopConnection + .delete({ where: { userId: user.id } }) + .catch(() => {}); + + await prisma.user.update({ + where: { id: user.id }, + data: { + whoopClientIdEncrypted: null, + whoopClientSecretEncrypted: null, + }, + }); + + return apiSuccess({ deleted: true }); +}); diff --git a/src/app/api/whoop/disconnect/__tests__/route.test.ts b/src/app/api/whoop/disconnect/__tests__/route.test.ts new file mode 100644 index 000000000..f385e835d --- /dev/null +++ b/src/app/api/whoop/disconnect/__tests__/route.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + whoopConnection: { findUnique: vi.fn(), delete: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { markDisconnected } from "@/lib/integrations/status"; + +const connFind = prisma.whoopConnection.findUnique as ReturnType; +const connDelete = prisma.whoopConnection.delete as ReturnType; + +describe("POST /api/whoop/disconnect", () => { + beforeEach(() => vi.clearAllMocks()); + + it("404s when there is no connection", async () => { + connFind.mockResolvedValue(null); + const res = (await ( + POST as unknown as () => Promise<{ status: number }> + )()) as { status: number }; + expect(res.status).toBe(404); + expect(connDelete).not.toHaveBeenCalled(); + }); + + it("deletes the connection and parks the integration on success", async () => { + connFind.mockResolvedValue({ id: "c1", userId: "u1" }); + connDelete.mockResolvedValue({}); + const res = (await (POST as unknown as () => Promise<{ data: unknown }>)()) + .data as { disconnected: boolean }; + expect(res.disconnected).toBe(true); + expect(connDelete).toHaveBeenCalledWith({ where: { userId: "u1" } }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "whoop"); + }); +}); diff --git a/src/app/api/whoop/disconnect/route.ts b/src/app/api/whoop/disconnect/route.ts new file mode 100644 index 000000000..36f37ea8e --- /dev/null +++ b/src/app/api/whoop/disconnect/route.ts @@ -0,0 +1,42 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { markDisconnected } from "@/lib/integrations/status"; + +/** + * Disconnect the WHOOP integration for the current user (v1.11.0). + * + * Clears the stored OAuth tokens by deleting the `WhoopConnection` row and + * parks the integration status at `disconnected`. WHOOP webhook subscriptions + * are app-level (registered once per dev app), so there is no per-user + * unsubscribe call to make — unlike Withings, which subscribes per category. + * The BYO-key credentials on `User` are left intact so a reconnect doesn't + * force the user to re-paste them; use the credentials DELETE endpoint to + * remove those too. + */ +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "whoop.disconnect" } }); + + const connection = await prisma.whoopConnection.findUnique({ + where: { userId: user.id }, + }); + + if (!connection) { + return apiError("No WHOOP connection", 404); + } + + await prisma.whoopConnection.delete({ + where: { userId: user.id }, + }); + + await auditLog("whoop.disconnect", { + userId: user.id, + }); + + await markDisconnected(user.id, "whoop"); + + return apiSuccess({ disconnected: true }); +}); diff --git a/src/app/api/whoop/status/__tests__/route.test.ts b/src/app/api/whoop/status/__tests__/route.test.ts new file mode 100644 index 000000000..1370de20d --- /dev/null +++ b/src/app/api/whoop/status/__tests__/route.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + whoopConnection: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null }), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; + +const userFind = prisma.user.findUnique as ReturnType; +const connFind = prisma.whoopConnection.findUnique as ReturnType; + +describe("GET /api/whoop/status", () => { + beforeEach(() => vi.clearAllMocks()); + + it("reports not-connected, not-configured when nothing is set", async () => { + userFind.mockResolvedValue(null); + connFind.mockResolvedValue(null); + const res = (await (GET as unknown as () => Promise<{ data: unknown }>)()) + .data as { connected: boolean; configured: boolean }; + expect(res.connected).toBe(false); + expect(res.configured).toBe(false); + }); + + it("reports configured when credentials exist but no connection", async () => { + userFind.mockResolvedValue({ + whoopClientIdEncrypted: "x", + whoopClientSecretEncrypted: "y", + }); + connFind.mockResolvedValue(null); + const res = (await (GET as unknown as () => Promise<{ data: unknown }>)()) + .data as { connected: boolean; configured: boolean }; + expect(res.connected).toBe(false); + expect(res.configured).toBe(true); + }); + + it("reports connection state with token expiry and backfill flag", async () => { + userFind.mockResolvedValue({ + whoopClientIdEncrypted: "x", + whoopClientSecretEncrypted: "y", + }); + connFind.mockResolvedValue({ + whoopUserId: "w1", + lastSyncedAt: new Date("2026-06-03T00:00:00Z"), + tokenExpiresAt: new Date(Date.now() + 3_600_000), + backfillCompletedAt: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + scope: "offline read:recovery", + }); + const res = (await (GET as unknown as () => Promise<{ data: unknown }>)()) + .data as { + connected: boolean; + tokenExpired: boolean; + backfillCompleted: boolean; + }; + expect(res.connected).toBe(true); + expect(res.tokenExpired).toBe(false); + expect(res.backfillCompleted).toBe(false); + }); +}); diff --git a/src/app/api/whoop/status/route.ts b/src/app/api/whoop/status/route.ts new file mode 100644 index 000000000..eb301e367 --- /dev/null +++ b/src/app/api/whoop/status/route.ts @@ -0,0 +1,58 @@ +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; + +/** + * Get WHOOP connection status for the current user (v1.11.0). + * + * Mirrors the Withings status route: `configured` checks the per-user BYO-key + * credentials, the connection row reports last-sync + token expiry + + * backfill progress. The token-refresh-in-status dance Withings does is not + * needed here — WHOOP `tokenExpired` is reported straight off the row and the + * sync path refreshes lazily via `getValidToken`. + */ +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "whoop.status" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + whoopClientIdEncrypted: true, + whoopClientSecretEncrypted: true, + }, + }); + + const configured = + !!dbUser?.whoopClientIdEncrypted && !!dbUser?.whoopClientSecretEncrypted; + + const connection = await prisma.whoopConnection.findUnique({ + where: { userId: user.id }, + select: { + whoopUserId: true, + lastSyncedAt: true, + tokenExpiresAt: true, + backfillCompletedAt: true, + createdAt: true, + scope: true, + }, + }); + + if (!connection) { + return apiSuccess({ connected: false, configured }); + } + + const tokenExpired = connection.tokenExpiresAt.getTime() <= Date.now(); + + return apiSuccess({ + connected: true, + configured, + lastSyncedAt: connection.lastSyncedAt, + connectedAt: connection.createdAt, + tokenExpired, + tokenExpiresAt: connection.tokenExpiresAt, + backfillCompleted: !!connection.backfillCompletedAt, + scope: connection.scope, + }); +}); diff --git a/src/app/api/whoop/sync/__tests__/route.test.ts b/src/app/api/whoop/sync/__tests__/route.test.ts new file mode 100644 index 000000000..953ae2c02 --- /dev/null +++ b/src/app/api/whoop/sync/__tests__/route.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/whoop/sync", () => ({ syncUserWhoop: vi.fn() })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null }), +})); + +import { POST } from "../route"; +import { syncUserWhoop } from "@/lib/whoop/sync"; + +const sync = syncUserWhoop as ReturnType; + +function req(body?: unknown): NextRequest { + return new NextRequest("http://localhost/api/whoop/sync", { + method: "POST", + body: body === undefined ? undefined : JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("POST /api/whoop/sync", () => { + beforeEach(() => vi.clearAllMocks()); + + it("triggers an incremental sync by default", async () => { + sync.mockResolvedValue(7); + const res = (await ( + POST as unknown as (r: NextRequest) => Promise<{ data: unknown }> + )(req({}))) as { data: { imported: number; fullSync: boolean } }; + expect(res.data.imported).toBe(7); + expect(res.data.fullSync).toBe(false); + expect(sync).toHaveBeenCalledWith("u1", { fullSync: false }); + }); + + it("honours fullSync: true", async () => { + sync.mockResolvedValue(99); + const res = (await ( + POST as unknown as (r: NextRequest) => Promise<{ data: unknown }> + )(req({ fullSync: true }))) as { data: { fullSync: boolean } }; + expect(res.data.fullSync).toBe(true); + expect(sync).toHaveBeenCalledWith("u1", { fullSync: true }); + }); +}); diff --git a/src/app/api/whoop/sync/route.ts b/src/app/api/whoop/sync/route.ts new file mode 100644 index 000000000..da912d4a0 --- /dev/null +++ b/src/app/api/whoop/sync/route.ts @@ -0,0 +1,27 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { syncUserWhoop } from "@/lib/whoop/sync"; +import { NextRequest } from "next/server"; + +/** + * Manually trigger a WHOOP sync for the current user (v1.11.0). + * + * Mirrors the Withings manual-sync route: incremental by default, full + * history when `{ fullSync: true }` is posted. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "whoop.sync" } }); + + let fullSync = false; + try { + const body = await request.json(); + fullSync = body?.fullSync === true; + } catch { + // no body provided -> default incremental sync + } + + const imported = await syncUserWhoop(user.id, { fullSync }); + return apiSuccess({ imported, fullSync }); +}); diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 73f5ecb71..920e506a8 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + Activity, AlertCircle, Download, Link2, @@ -59,7 +60,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" | "moodlog"; +type IntegrationKey = "withings" | "whoop" | "moodlog"; type IntegrationState = | "connected" | "error_transient" @@ -194,6 +195,7 @@ export function IntegrationsSection() { const moodLogEnabled = globalServices?.moodLogGlobal ?? true; const withingsViewModel = pickStatus(integrationStatus, "withings"); + const whoopViewModel = pickStatus(integrationStatus, "whoop"); const moodLogViewModel = pickStatus(integrationStatus, "moodlog"); return ( @@ -217,6 +219,7 @@ export function IntegrationsSection() { isAuthenticated={isAuthenticated} viewModel={withingsViewModel} /> + {moodLogEnabled && } ); @@ -692,6 +695,347 @@ function WithingsCard({ ); } +function WhoopCard({ + isAuthenticated, + viewModel, +}: { + isAuthenticated: boolean; + viewModel: IntegrationStatusViewModel | undefined; +}) { + const { t } = useTranslations(); + const [syncing, setSyncing] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( + null, + ); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [credsSaving, setCredsSaving] = useState(false); + const [credsMsg, setCredsMsg] = useState(null); + const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( + null, + ); + const queryClient = useQueryClient(); + + const { data: status } = useQuery({ + queryKey: queryKeys.whoopStatus(), + queryFn: async () => { + const res = await fetch("/api/whoop/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/whoop/disconnect", { method: "POST" }); + if (!res.ok) throw new Error("Failed"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + }, + }); + + async function handleSync(fullSync = false) { + setSyncing(true); + setSyncMsg(null); + setSyncMsgType(null); + try { + const res = await fetch("/api/whoop/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + }); + const json = await res.json(); + if (res.ok) { + setSyncMsg(t("settings.whoopSyncResult", { count: json.data.imported })); + setSyncMsgType("success"); + void invalidateKeys(queryClient, measurementDependentKeys); + queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + setSyncMsg(json.error || t("settings.whoopSyncFailed")); + setSyncMsgType("error"); + } + } catch { + setSyncMsg(t("settings.whoopSyncFailed")); + 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/whoop/credentials", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + + if (res.ok) { + setCredsMsg(t("settings.whoopCredentialsSaved")); + setCredsMsgType("success"); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); + } else { + try { + const json = await res.json(); + setCredsMsg(json.error || t("settings.savingError")); + } catch { + setCredsMsg(t("settings.savingError")); + } + setCredsMsgType("error"); + } + } catch { + setCredsMsg(t("common.networkError")); + setCredsMsgType("error"); + } + setCredsSaving(false); + } + + const pillState: IntegrationPillState = status?.connected + ? pillStateFor(viewModel) + : "disconnected"; + const pillLastSyncAt = + status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; + const errorMessage = + (pillState === "error" || pillState === "parked") && viewModel?.lastError + ? viewModel.lastError + : null; + + return ( +
+
+
+ +

{t("settings.whoop")}

+
+ +
+

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

+ +
+ +
+ {errorMessage && } + +
+

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

+

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

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

+ {credsMsg} +

+ )} +
+
+ + {status?.connected ? ( + <> +
+ + + + + + + + + {t("settings.whoopFullSyncTitle")} + + + {t("settings.whoopFullSyncDescription")} + + + + {t("common.cancel")} + handleSync(true)}> + {t("settings.whoopSynchronize")} + + + + + + + + + + + + {t("settings.whoopDisconnectTitle")} + + + {t("settings.whoopDisconnectDescription")} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t("settings.whoopDisconnect")} + + + + +
+ {status?.backfillCompleted === false && ( +

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

+ )} + {syncMsg && ( +

+ {syncMsg} +

+ )} + + ) : status?.configured ? ( + + ) : ( +
+ {t("settings.whoopNoCredentials")} +
+ )} +
+
+ ); +} + function MoodLogCard({ viewModel, }: { diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 8136de5c5..ed241217d 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -360,6 +360,12 @@ export const queryKeys = { * mutation invalidates both at once. */ withingsStatus: () => ["withings", "status"] as const, + whoop: () => ["whoop"] as const, + /** + * Per-card WHOOP status read. Shares the `["whoop"]` prefix with `whoop()` + * so a disconnect / credentials mutation invalidates both at once. + */ + whoopStatus: () => ["whoop", "status"] as const, // v1.4.32 — workout list + detail caches. `workouts()` is the // root key invalidated by the batch-ingest mutation; the recent + diff --git a/src/lib/validations/whoop.ts b/src/lib/validations/whoop.ts new file mode 100644 index 000000000..ea33fe786 --- /dev/null +++ b/src/lib/validations/whoop.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; + +/** + * Per-user WHOOP BYO-key credentials. Each self-hoster registers their own + * WHOOP dev app and pastes the client id/secret into Settings (the per-app + * authorized-user cap makes a single shared app unworkable for a + * multi-operator product). Stored encrypted on `User`. + */ +export const whoopCredentialsSchema = z.object({ + clientId: z.string().min(1).max(200), + clientSecret: z.string().min(1).max(200), +}); + +export type WhoopCredentialsInput = z.infer;