feat(whoop): settings surface — status, disconnect, credentials, sync routes + connect card

Fill the WHOOP route set beside the OAuth connect/callback: status reports
connection + token expiry + backfill progress, disconnect revokes the stored
tokens and parks the integration, credentials saves/clears the per-user BYO
client id/secret encrypted at rest, sync triggers a manual incremental or full
pull. Add a WHOOP card to Settings -> Integrations mirroring the Withings card
(BYO-keys form, connect/disconnect, status pill, last-sync, backfill notice)
and surface whoop rows in the combined integrations-status envelope so the pill
reflects real failure state. WHOOP lives under the existing integrations
section -- no new slug.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 02:34:33 +02:00
1 parent d4686ea2a6
commit 008160dc72
18 files changed
+1012 -2

No files matched your search

+23
View File
@@ -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": {
+23
View File
@@ -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": {
+23
View File
@@ -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": {
+23
View File
@@ -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": {
+23
View File
@@ -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": {
+23
View File
@@ -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": {
+34 -1
View File
@@ -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;
}
@@ -0,0 +1,94 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
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<typeof vi.fn>).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,
},
});
});
});
+77
View File
@@ -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 });
});
@@ -0,0 +1,51 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
const connDelete = prisma.whoopConnection.delete as ReturnType<typeof vi.fn>;
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");
});
});
+42
View File
@@ -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 });
});
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
const connFind = prisma.whoopConnection.findUnique as ReturnType<typeof vi.fn>;
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);
});
});
+58
View File
@@ -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,
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => 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<typeof vi.fn>;
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 });
});
});
+27
View File
@@ -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 });
});
@@ -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}
/>
<WhoopCard isAuthenticated={isAuthenticated} viewModel={whoopViewModel} />
{moodLogEnabled && <MoodLogCard viewModel={moodLogViewModel} />}
</section>
);
@@ -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<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.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 (
<div 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">
<Activity className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.whoop")}</h2>
</div>
<IntegrationStatusPill state={pillState} lastSyncAt={pillLastSyncAt} />
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.whoopDescription")}
</p>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<div className="mt-4 space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
{t("settings.whoopCredentials")}
</h3>
<p className="text-muted-foreground text-xs">
{t("settings.whoopCredentialsHelp")}
</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="whoop-clientid">
{t("settings.whoopClientId")}
</Label>
<Input
id="whoop-clientid"
value={clientId}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="whoop-secret">
{t("settings.whoopClientSecret")}
</Label>
<PasswordInput
id="whoop-secret"
value={clientSecret}
onChange={(e) => 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"
/>
</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.whoopSaveCredentials")}
</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.whoopSync")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={syncing}>
<Download className="mr-1 h-3.5 w-3.5" />
{t("settings.whoopFullSync")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.whoopFullSyncTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.whoopFullSyncDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => handleSync(true)}>
{t("settings.whoopSynchronize")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive"
>
<Unlink className="mr-1 h-3.5 w-3.5" />
{t("settings.whoopDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.whoopDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.whoopDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => disconnect.mutate()}
>
{t("settings.whoopDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{status?.backfillCompleted === false && (
<p className="text-muted-foreground text-xs">
{t("settings.whoopBackfillInProgress")}
</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/whoop/connect";
}}
>
<Link2 className="mr-2 h-4 w-4" />
{t("settings.whoopConnect")}
</Button>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
{t("settings.whoopNoCredentials")}
</div>
)}
</div>
</div>
);
}
function MoodLogCard({
viewModel,
}: {
+6
View File
@@ -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 +
+14
View File
@@ -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<typeof whoopCredentialsSchema>;