mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
d4686ea2a6
commit
008160dc72
18 files changed
+1012
-2
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user