merge(integrations): honest Polar sleep timeline, shared reconstruction, OAuth returns, status envelope

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 01:35:27 +02:00
commit d7691b6cbe
26 files changed
+1045 -128

No files matched your search

+24
View File
@@ -4467,6 +4467,18 @@
"whoopConnect": "Mit WHOOP verbinden",
"whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.",
"whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…",
"whoopOauthConnected": "WHOOP verbunden — die erste Synchronisierung startet im Hintergrund.",
"whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen",
"whoopOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
"state": "Die Antwort von WHOOP passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
"nocode": "WHOOP hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im WHOOP-Dialog.",
"nocreds": "WHOOP ist nicht konfiguriert. Hinterlege oben deine WHOOP-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.",
@@ -4494,6 +4506,18 @@
"fitbitConnect": "Mit Google Health verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
"fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
"fitbitOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.",
"fitbitOauthFailed": "Google-Health-Verbindung fehlgeschlagen",
"fitbitOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
"state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
"nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.",
"nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"integrations": {
"withings": {
"reconnect": {
+24
View File
@@ -4467,6 +4467,18 @@
"whoopConnect": "Connect with WHOOP",
"whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.",
"whoopBackfillInProgress": "Importing your WHOOP history in the background…",
"whoopOauthConnected": "WHOOP connected — the first sync starts in the background.",
"whoopOauthFailed": "WHOOP connection failed",
"whoopOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
"state": "The response from WHOOP did not match any open connection request. Please start the connection again.",
"expired": "The connection request expired before it completed. Please start the connection again.",
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
"nocode": "WHOOP did not return an authorization code. Try again and allow access in the WHOOP dialog.",
"nocreds": "WHOOP is not configured. Add your WHOOP OAuth credentials above and try again.",
"token": "The token exchange with WHOOP failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.",
@@ -4494,6 +4506,18 @@
"fitbitConnect": "Connect with Google Health",
"fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.",
"fitbitBackfillInProgress": "Importing your Google Health history in the background…",
"fitbitOauthConnected": "Google Health connected — the first sync starts in the background.",
"fitbitOauthFailed": "Google Health connection failed",
"fitbitOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
"state": "The response from Google did not match any open connection request. Please start the connection again.",
"expired": "The connection request expired before it completed. Please start the connection again.",
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
"nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.",
"nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.",
"token": "The token exchange with Google failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"integrations": {
"withings": {
"reconnect": {
+24
View File
@@ -4467,6 +4467,18 @@
"whoopConnect": "Conectar con WHOOP",
"whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.",
"whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…",
"whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.",
"whoopOauthFailed": "Error al conectar con WHOOP",
"whoopOauthError": {
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
"state": "La respuesta de WHOOP no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
"nocode": "WHOOP no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de WHOOP.",
"nocreds": "WHOOP no está configurado. Añade tus credenciales OAuth de WHOOP arriba e inténtalo de nuevo.",
"token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit y Pixel",
"fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.",
@@ -4494,6 +4506,18 @@
"fitbitConnect": "Conectar con Google Health",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
"fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
"fitbitOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
"fitbitOauthFailed": "Error al conectar con Google Health",
"fitbitOauthError": {
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
"state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
"nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Google.",
"nocreds": "Google Health no está configurado. Añade tus credenciales OAuth de Google arriba e inténtalo de nuevo.",
"token": "El intercambio de token con Google falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"integrations": {
"withings": {
"reconnect": {
+24
View File
@@ -4467,6 +4467,18 @@
"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…",
"whoopOauthConnected": "WHOOP connecté — la première synchronisation démarre en arrière-plan.",
"whoopOauthFailed": "Échec de la connexion à WHOOP",
"whoopOauthError": {
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
"state": "La réponse de WHOOP ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
"nocode": "WHOOP n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre WHOOP.",
"nocreds": "WHOOP n'est pas configuré. Ajoutez vos identifiants OAuth WHOOP ci-dessus et réessayez.",
"token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit et Pixel",
"fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.",
@@ -4494,6 +4506,18 @@
"fitbitConnect": "Se connecter à Google Health",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.",
"fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…",
"fitbitOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.",
"fitbitOauthFailed": "Échec de la connexion à Google Health",
"fitbitOauthError": {
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
"state": "La réponse de Google ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
"nocode": "Google n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Google.",
"nocreds": "Google Health n'est pas configuré. Ajoutez vos identifiants OAuth Google ci-dessus et réessayez.",
"token": "L'échange de jeton avec Google a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"integrations": {
"withings": {
"reconnect": {
+24
View File
@@ -4467,6 +4467,18 @@
"whoopConnect": "Connetti con WHOOP",
"whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.",
"whoopBackfillInProgress": "Importazione dello storico WHOOP in background…",
"whoopOauthConnected": "WHOOP collegato — la prima sincronizzazione parte in background.",
"whoopOauthFailed": "Connessione a WHOOP non riuscita",
"whoopOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
"state": "La risposta di WHOOP non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
"nocode": "WHOOP non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di WHOOP.",
"nocreds": "WHOOP non è configurato. Aggiungi le tue credenziali OAuth di WHOOP qui sopra e riprova.",
"token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit e Pixel",
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.",
@@ -4494,6 +4506,18 @@
"fitbitConnect": "Connetti con Google Health",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
"fitbitBackfillInProgress": "Importazione dello storico Google Health in background…",
"fitbitOauthConnected": "Google Health collegato — la prima sincronizzazione parte in background.",
"fitbitOauthFailed": "Connessione a Google Health non riuscita",
"fitbitOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
"state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
"nocode": "Google non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Google.",
"nocreds": "Google Health non è configurato. Aggiungi le tue credenziali OAuth di Google qui sopra e riprova.",
"token": "Lo scambio del token con Google è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"integrations": {
"withings": {
"reconnect": {
+24
View File
@@ -4467,6 +4467,18 @@
"whoopConnect": "Połącz z WHOOP",
"whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.",
"whoopBackfillInProgress": "Importowanie historii WHOOP w tle…",
"whoopOauthConnected": "WHOOP połączony — pierwsza synchronizacja startuje w tle.",
"whoopOauthFailed": "Połączenie z WHOOP nie powiodło się",
"whoopOauthError": {
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
"state": "Odpowiedź z WHOOP nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
"nocode": "WHOOP nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie WHOOP.",
"nocreds": "WHOOP nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth WHOOP i spróbuj ponownie.",
"token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit i Pixel",
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.",
@@ -4494,6 +4506,18 @@
"fitbitConnect": "Połącz z Google Health",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.",
"fitbitBackfillInProgress": "Importowanie historii Google Health w tle…",
"fitbitOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.",
"fitbitOauthFailed": "Połączenie z Google Health nie powiodło się",
"fitbitOauthError": {
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
"state": "Odpowiedź z Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
"nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.",
"nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.",
"token": "Wymiana tokena z Google nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"integrations": {
"withings": {
"reconnect": {
@@ -0,0 +1,79 @@
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() },
fitbitConnection: { delete: vi.fn() },
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
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 }),
safeJson: async (req: NextRequest) => {
try {
return { data: await req.json(), error: null };
} catch {
return { data: null, error: { status: 400 } };
}
},
}));
import { PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/fitbit/credentials", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
describe("/api/fitbit/credentials", () => {
beforeEach(() => vi.clearAllMocks());
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("DELETE clears credentials, audits the teardown, and parks the ledger (04-L1 parity)", async () => {
userUpdate.mockResolvedValue({});
(
prisma.fitbitConnection.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: {
fitbitClientIdEncrypted: null,
fitbitClientSecretEncrypted: null,
},
});
expect(auditLog).toHaveBeenCalledWith("fitbit.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "fitbit");
});
});
+8
View File
@@ -1,8 +1,10 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
import { fitbitCredentialsSchema } from "@/lib/validations/fitbit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -60,6 +62,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
/**
* Delete Fitbit credentials and the active connection.
*
* Audits the teardown and parks the integration ledger at `disconnected` for
* parity with the Polar / Oura credential-DELETE so a sensitive op leaves a
* uniform audit trail and the status snapshot does not linger at a stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
@@ -76,6 +82,8 @@ export const DELETE = apiHandler(async () => {
fitbitClientSecretEncrypted: null,
},
});
await auditLog("fitbit.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "fitbit");
return apiSuccess({ deleted: true });
});
@@ -0,0 +1,115 @@
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() },
withingsConnection: { findUnique: vi.fn(async () => null) },
whoopConnection: { findUnique: vi.fn(async () => null) },
fitbitConnection: { findUnique: vi.fn(async () => null) },
moodEntry: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
const ledger: Record<string, unknown> = {
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
};
vi.mock("@/lib/integrations/status", () => ({
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
integration,
...ledger,
})),
getPersistentFailureThreshold: () => 5,
}));
vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false }));
vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null }));
const polarAvailable = vi.fn(async () => true);
const ouraAvailable = vi.fn(async () => false);
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: () => polarAvailable(),
}));
vi.mock("@/lib/oura/credentials", () => ({
getOuraClientCredentials: () => ouraAvailable(),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
type Entry = {
integration: string;
connected?: boolean;
configured?: boolean;
available?: boolean;
hasOwnCredentials?: boolean;
};
async function fetchEntries(): Promise<Entry[]> {
const res = (await (GET as unknown as () => Promise<{ data: unknown }>)())
.data as { integrations: Entry[] };
return res.integrations;
}
describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
beforeEach(() => {
vi.clearAllMocks();
polarAvailable.mockResolvedValue(true);
ouraAvailable.mockResolvedValue(false);
});
it("includes polar + oura entries on the consolidated envelope", async () => {
userFind.mockResolvedValue({
polarAccessTokenEncrypted: "tok",
polarClientIdEncrypted: "id",
polarClientSecretEncrypted: "sec",
ouraAccessTokenEncrypted: null,
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: null,
});
const entries = await fetchEntries();
const keys = entries.map((e) => e.integration);
expect(keys).toEqual(
expect.arrayContaining(["polar", "oura"]),
);
const polar = entries.find((e) => e.integration === "polar")!;
expect(polar.connected).toBe(true);
expect(polar.configured).toBe(true);
expect(polar.available).toBe(true);
expect(polar.hasOwnCredentials).toBe(true);
const oura = entries.find((e) => e.integration === "oura")!;
expect(oura.connected).toBe(false);
expect(oura.available).toBe(false);
expect(oura.hasOwnCredentials).toBe(false);
});
it("reports polar disconnected when no access token is stored", async () => {
userFind.mockResolvedValue({
polarAccessTokenEncrypted: null,
polarClientIdEncrypted: null,
polarClientSecretEncrypted: null,
});
const entries = await fetchEntries();
const polar = entries.find((e) => e.integration === "polar")!;
expect(polar.connected).toBe(false);
expect(polar.configured).toBe(false);
});
});
+51
View File
@@ -24,6 +24,8 @@ import {
getPersistentFailureThreshold,
type IntegrationKey,
} from "@/lib/integrations/status";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
@@ -38,16 +40,25 @@ export const GET = apiHandler(async () => {
moodLogStatus,
whoopStatus,
fitbitStatus,
polarStatus,
ouraStatus,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
moodLogEntryCount,
// v1.17.1 — `available` reports whether usable OAuth credentials resolve
// (per-user BYO first, then the shared env app), mirroring the per-card
// /api/<provider>/status the consolidated envelope now replaces.
polarAvailable,
ouraAvailable,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
getIntegrationStatus(user.id, "whoop"),
getIntegrationStatus(user.id, "fitbit"),
getIntegrationStatus(user.id, "polar"),
getIntegrationStatus(user.id, "oura"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -57,6 +68,12 @@ export const GET = apiHandler(async () => {
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
polarAccessTokenEncrypted: true,
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
@@ -95,6 +112,8 @@ export const GET = apiHandler(async () => {
prisma.moodEntry.count({
where: { userId: user.id, deletedAt: null },
}),
getPolarClientCredentials(user.id).then((c) => !!c),
getOuraClientCredentials(user.id).then((c) => !!c),
]);
const now = Date.now();
@@ -164,6 +183,28 @@ export const GET = apiHandler(async () => {
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
} satisfies IntegrationViewModel & FitbitExtras,
{
...polarStatus,
// `connected` = a stored access token; `configured` mirrors it (the
// OAuth card has no separate "credentials saved but disconnected" view
// beyond `hasOwnCredentials`). The card greys out the connect button
// when no usable credentials resolve (`available`).
connected: !!dbUser?.polarAccessTokenEncrypted,
configured: !!dbUser?.polarAccessTokenEncrypted,
available: polarAvailable,
hasOwnCredentials:
!!dbUser?.polarClientIdEncrypted &&
!!dbUser?.polarClientSecretEncrypted,
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...ouraStatus,
connected: !!dbUser?.ouraAccessTokenEncrypted,
configured: !!dbUser?.ouraAccessTokenEncrypted,
available: ouraAvailable,
hasOwnCredentials:
!!dbUser?.ouraClientIdEncrypted &&
!!dbUser?.ouraClientSecretEncrypted,
} satisfies IntegrationViewModel & OAuthProviderExtras,
],
});
});
@@ -214,3 +255,13 @@ interface FitbitExtras {
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
}
// v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the
// per-user BYO-key flags the shared OAuth card reads instead of the dedicated
// /api/<provider>/status round-trip the page used to fire per card.
interface OAuthProviderExtras {
connected: boolean;
configured: boolean;
available: boolean;
hasOwnCredentials: boolean;
}
@@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
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 }),
@@ -30,6 +32,8 @@ vi.mock("@/lib/api-response", () => ({
import { GET, PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
@@ -91,4 +95,16 @@ describe("/api/whoop/credentials", () => {
},
});
});
it("DELETE audits the teardown and parks the ledger (04-L1 parity)", async () => {
userUpdate.mockResolvedValue({});
(prisma.whoopConnection.delete as ReturnType<typeof vi.fn>).mockResolvedValue(
{},
);
await (DELETE as unknown as () => Promise<unknown>)();
expect(auditLog).toHaveBeenCalledWith("whoop.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "whoop");
});
});
+8
View File
@@ -1,8 +1,10 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
import { whoopCredentialsSchema } from "@/lib/validations/whoop";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -58,6 +60,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
/**
* Delete WHOOP credentials and the active connection.
*
* Audits the teardown and parks the integration ledger at `disconnected` for
* parity with the Polar / Oura credential-DELETE so a sensitive op leaves a
* uniform audit trail and the status snapshot does not linger at a stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
@@ -74,6 +80,8 @@ export const DELETE = apiHandler(async () => {
whoopClientSecretEncrypted: null,
},
});
await auditLog("whoop.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "whoop");
return apiSuccess({ deleted: true });
});
@@ -0,0 +1,67 @@
/**
* v1.17.1 (04-M1) — the WHOOP / Fitbit OAuth callbacks redirect back with
* `?whoop=connected|error` / `?fitbit=connected|error&reason=<tag>`, but the
* settings page only read the param for Withings/Polar/Oura — so a user
* returning from a WHOOP or Fitbit round-trip landed on a silently unchanged
* page. These pin the four-provider outcome parsing + reason-key resolution.
*/
import { describe, it, expect } from "vitest";
import {
parseOAuthOutcome,
oauthReasonKey,
} from "../integrations-section";
describe("parseOAuthOutcome", () => {
it("reads a connected outcome for every OAuth provider", () => {
for (const p of ["polar", "oura", "whoop", "fitbit"] as const) {
expect(parseOAuthOutcome(`?${p}=connected`)).toEqual({
provider: p,
kind: "connected",
});
}
});
it("reads WHOOP + Fitbit error outcomes with their reason tag (the gap fix)", () => {
expect(parseOAuthOutcome("?whoop=error&reason=token")).toEqual({
provider: "whoop",
kind: "error",
reason: "token",
});
expect(parseOAuthOutcome("?fitbit=error&reason=expired")).toEqual({
provider: "fitbit",
kind: "error",
reason: "expired",
});
});
it("defaults a missing reason to 'unknown'", () => {
expect(parseOAuthOutcome("?whoop=error")).toEqual({
provider: "whoop",
kind: "error",
reason: "unknown",
});
});
it("returns null when no provider param is present", () => {
expect(parseOAuthOutcome("?foo=bar")).toBeNull();
expect(parseOAuthOutcome("")).toBeNull();
});
});
describe("oauthReasonKey", () => {
it("maps a known tag to the provider-specific key", () => {
expect(oauthReasonKey("whoop", "token")).toBe(
"settings.whoopOauthError.token",
);
expect(oauthReasonKey("fitbit", "expired")).toBe(
"settings.fitbitOauthError.expired",
);
});
it("falls back to generic for an unknown tag", () => {
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
"settings.whoopOauthError.generic",
);
});
});
+115 -33
View File
@@ -8,11 +8,13 @@ import { toast } from "sonner";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
import { WithingsCard } from "@/components/settings/integrations/withings-card";
@@ -43,6 +45,97 @@ type WithingsOauthOutcome =
| { kind: "connected" }
| { kind: "error"; reason: string };
/**
* The OAuth providers whose callbacks redirect back to the settings page with a
* `?<provider>=connected|error&reason=<tag>` outcome param. Polar/Oura own a
* per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
* are invalidated on a successful return so the card repaints either way.
*/
const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const;
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
const OAUTH_OUTCOME_KEYS: Record<
OAuthOutcomeProvider,
() => readonly unknown[]
> = {
polar: queryKeys.polar,
oura: queryKeys.oura,
whoop: queryKeys.whoop,
fitbit: queryKeys.fitbit,
};
/**
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
* message; anything else falls back to the provider's `generic` copy. Union of
* the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`).
*/
const OAUTH_OUTCOME_REASONS = new Set([
"csrf1",
"state",
"cross_user",
"nocode",
"nocreds",
"token",
"rate_limited",
"expired",
]);
type OAuthOutcome =
| { provider: OAuthOutcomeProvider; kind: "connected" }
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
/**
* Parse the OAuth-return outcome from a URL query string. Reads the first
* provider whose `?<provider>=connected|error` param is present, in
* `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider
* coverage is unit-testable without a browser.
*/
export function parseOAuthOutcome(search: string): OAuthOutcome | null {
const params = new URLSearchParams(search);
for (const provider of OAUTH_OUTCOME_PROVIDERS) {
const v = params.get(provider);
if (v === "connected") return { provider, kind: "connected" };
if (v === "error") {
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
}
}
return null;
}
/**
* Resolve the i18n key for an error reason tag. Known tags map to the
* provider-specific message; anything else falls back to `generic`.
*/
export function oauthReasonKey(
provider: OAuthOutcomeProvider,
reason: string,
): string {
return OAUTH_OUTCOME_REASONS.has(reason)
? `settings.${provider}OauthError.${reason}`
: `settings.${provider}OauthError.generic`;
}
/**
* Adapt the consolidated-envelope view-model into the OAuth card's status
* shape. Returns `undefined` when the envelope hasn't loaded so the card
* renders its loading/disconnected default rather than a half-populated state.
*/
function toOAuthStatus(
vm: IntegrationStatusViewModel | undefined,
): OAuthProviderStatus | undefined {
if (!vm) return undefined;
return {
connected: vm.connected ?? false,
configured: vm.configured ?? false,
available: vm.available ?? false,
hasOwnCredentials: vm.hasOwnCredentials,
state: vm.state,
lastSuccessAt: vm.lastSuccessAt,
lastAttemptAt: vm.lastAttemptAt,
lastError: vm.lastError,
};
}
export function IntegrationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -92,23 +185,16 @@ export function IntegrationsSection() {
}
}, [withingsOauthOutcome, router, queryClient, t]);
// v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers.
// The Polar / Oura callbacks redirect back with `?<provider>=connected` or
// `?<provider>=error&reason=<tag>`; surface the outcome as a toast and scrub
// the one-shot params so a reload doesn't replay it.
const [oauthOutcome] = useState<
{ provider: "polar" | "oura"; kind: "connected" } | { provider: "polar" | "oura"; kind: "error"; reason: string } | null
>(() => {
// v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers.
// The Polar / Oura / WHOOP / Fitbit callbacks redirect back with
// `?<provider>=connected` or `?<provider>=error&reason=<tag>`; surface the
// outcome as a toast and scrub the one-shot params so a reload doesn't replay
// it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a
// WHOOP or Fitbit round-trip landed on a silently unchanged settings page,
// the same gap the Withings handler was written to close.
const [oauthOutcome] = useState<OAuthOutcome | null>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
for (const provider of ["polar", "oura"] as const) {
const v = params.get(provider);
if (v === "connected") return { provider, kind: "connected" };
if (v === "error") {
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
}
}
return null;
return parseOAuthOutcome(window.location.search);
});
useEffect(() => {
@@ -118,27 +204,19 @@ export function IntegrationsSection() {
url.searchParams.delete(provider);
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
const key = provider === "polar" ? queryKeys.polar() : queryKeys.oura();
if (oauthOutcome.kind === "connected") {
toast.success(t(`settings.${provider}OauthConnected`));
queryClient.invalidateQueries({ queryKey: key });
// Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
// consolidated envelope — invalidate both so the card repaints either way.
queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
// Known reason tags resolve to a specific message; anything else falls
// back to the generic copy (matching the Withings handler).
const knownReasons = new Set([
"csrf1",
"state",
"cross_user",
"nocode",
"nocreds",
"token",
"rate_limited",
]);
const reasonKey = knownReasons.has(oauthOutcome.reason)
? `settings.${provider}OauthError.${oauthOutcome.reason}`
: `settings.${provider}OauthError.generic`;
toast.error(t(`settings.${provider}OauthFailed`), {
description: t(reasonKey),
description: t(oauthReasonKey(provider, oauthOutcome.reason)),
duration: 10_000,
});
}
@@ -147,6 +225,10 @@ export function IntegrationsSection() {
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
// no longer fire their own /api/<provider>/status round-trip.
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
return (
<section
@@ -179,8 +261,8 @@ export function IntegrationsSection() {
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
<PolarCard enabled={isAuthenticated} />
<OuraCard enabled={isAuthenticated} />
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
<NightscoutCard enabled={isAuthenticated} />
</section>
);
@@ -23,7 +23,13 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { OAuthProviderCard } from "../oauth-provider-card";
function render({ credentials = false }: { credentials?: boolean } = {}) {
function render({
credentials = false,
viewModel,
}: {
credentials?: boolean;
viewModel?: Parameters<typeof OAuthProviderCard>[0]["viewModel"];
} = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<OAuthProviderCard
@@ -33,6 +39,7 @@ function render({ credentials = false }: { credentials?: boolean } = {}) {
icon={Watch}
dataHref="/insights/sleep"
credentials={credentials}
viewModel={viewModel}
/>
</I18nProvider>,
);
@@ -74,6 +81,25 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
expect(html).toContain('href="/insights/sleep"');
});
it("reads off the passed view-model instead of the per-card fetch (04-M2)", () => {
// The per-card useQuery mock returns null; the card must render the
// connected state from the supplied envelope view-model alone, proving the
// /api/<provider>/status round-trip is no longer the source.
statusPayload = null;
const html = render({
viewModel: {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: "2026-06-01T00:00:00.000Z",
lastError: null,
},
});
expect(html).toContain('data-testid="polar-data-link"');
expect(html).toContain("Test connection");
});
it("does not render the data link or test button when disconnected", () => {
statusPayload = {
connected: false,
@@ -36,7 +36,11 @@ import {
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
import {
invalidateKeys,
measurementDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { IntegrationSetupGuideLink } from "./setup-guide-link";
export interface OAuthProviderStatus {
@@ -85,6 +89,13 @@ export interface OAuthProviderCardProps {
* button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
credentials?: boolean;
enabled?: boolean;
/**
* When provided, the card reads its status from this view-model (sourced off
* the consolidated `/api/integrations/status` envelope) instead of firing its
* own `/api/<provider>/status` round-trip. v1.17.1 folds Polar/Oura onto the
* same envelope WHOOP/Fitbit already use, so the page reads from one source.
*/
viewModel?: OAuthProviderStatus;
}
export function OAuthProviderCard({
@@ -95,6 +106,7 @@ export function OAuthProviderCard({
dataHref,
credentials = false,
enabled = true,
viewModel,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -107,12 +119,17 @@ export function OAuthProviderCard({
null,
);
const { data: status } = useQuery({
// Read off the consolidated envelope when a view-model is passed; otherwise
// fall back to the per-card status fetch (still used by any caller that has
// not been migrated onto the envelope). The fetch is disabled once a
// view-model is supplied so the page makes one request, not one-per-card.
const { data: fetchedStatus } = useQuery({
queryKey: statusQueryKey,
queryFn: async () => apiGet<OAuthProviderStatus>(`/api/${provider}/status`),
enabled,
enabled: enabled && !viewModel,
refetchOnWindowFocus: true,
});
const status = viewModel ?? fetchedStatus;
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
@@ -134,6 +151,11 @@ export function OAuthProviderCard({
setClientId("");
setClientSecret("");
queryClient.invalidateQueries({ queryKey: statusQueryKey });
// The card may read off the consolidated envelope — invalidate it too
// so the saved-credentials state repaints regardless of the source.
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
try {
const json = await res.json();
@@ -156,6 +178,9 @@ export function OAuthProviderCard({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
},
});
@@ -6,10 +6,19 @@
import { CircleDot } from "lucide-react";
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function OuraCard({ enabled = true }: { enabled?: boolean }) {
export function OuraCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
return (
<OAuthProviderCard
provider="oura"
@@ -19,6 +28,7 @@ export function OuraCard({ enabled = true }: { enabled?: boolean }) {
dataHref="/insights/sleep"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
}
@@ -6,10 +6,19 @@
import { Watch } from "lucide-react";
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function PolarCard({ enabled = true }: { enabled?: boolean }) {
export function PolarCard({
enabled = true,
viewModel,
}: {
enabled?: boolean;
viewModel?: OAuthProviderStatus;
}) {
return (
<OAuthProviderCard
provider="polar"
@@ -19,6 +28,7 @@ export function PolarCard({ enabled = true }: { enabled?: boolean }) {
dataHref="/insights/sleep"
credentials
enabled={enabled}
viewModel={viewModel}
/>
);
}
@@ -18,7 +18,13 @@ import { queryKeys } from "@/lib/query-keys";
// 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.
export type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog";
export type IntegrationKey =
| "withings"
| "whoop"
| "fitbit"
| "moodlog"
| "polar"
| "oura";
export type IntegrationState =
| "connected"
| "error_transient"
@@ -52,6 +58,11 @@ export interface IntegrationStatusViewModel {
// moodLog webhook secret + entry count.
webhookSecret?: string | null;
entryCount?: number;
// Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available`
// greys out the connect button when no credentials resolve; `hasOwnCredentials`
// drives the saved-placeholder UI.
available?: boolean;
hasOwnCredentials?: boolean;
}
export interface IntegrationStatusEnvelope {
@@ -399,6 +399,22 @@ describe("reconstructSleepSessions", () => {
expect(new Set(ends).size).toBe(ends.length);
});
it("flags a Polar-won night as reconstructed (synthetic stage order)", () => {
// Polar exposes only per-stage duration totals, so its mapper reconstructs
// a contiguous CORE→DEEP→REM order exactly like WHOOP. The reader must label
// such a night reconstructed so the UI never presents the synthetic order as
// measured timing.
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "POLAR"),
srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "POLAR"),
srcRow("2026-06-04T05:00:00.000Z", "REM", 120, "POLAR"),
];
const sessions = reconstructSleepSessions(rows, "UTC");
expect(sessions).toHaveLength(1);
expect(sessions[0].source).toBe("POLAR");
expect(sessions[0].reconstructed).toBe(true);
});
it("does NOT flag an Apple-Health night as reconstructed (measured timeline)", () => {
const rows: SleepStageRow[] = [
srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
+6 -4
View File
@@ -285,13 +285,15 @@ const NO_SOURCE = "__none__";
* Sources whose per-segment stage ORDER is synthesised, not measured. WHOOP's
* v2 API exposes only per-stage duration totals (no onset timestamps), so the
* ingest mapper reconstructs a contiguous timeline in a fixed physiological
* order (`whoop/client.ts` `mapSleep`). A session won by such a source carries
* `reconstructed: true` so the UI labels it as an approximate layout. Apple
* Health / Withings / Fitbit all store a real per-segment series and stay off
* this set.
* order (`whoop/client.ts` + `polar/client.ts` `mapSleep`, both via the shared
* `reconstructContiguousSleepTimeline` builder). A session won by such a source
* carries `reconstructed: true` so the UI labels it as an approximate layout.
* Apple Health / Oura / Withings / Fitbit all store a real per-segment series
* and stay off this set.
*/
const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet<string> = new Set<string>([
"WHOOP",
"POLAR",
]);
/**
+47
View File
@@ -265,6 +265,53 @@ describe("mapSleep", () => {
).toBe(endMs);
});
it("emits an AWAKE segment from total_interruption_duration", () => {
const start = "2026-06-09T23:00:00+02:00"; // 21:00 UTC
const s: PolarSleep = {
date: "2026-06-10",
sleep_start_time: start,
sleep_end_time: "2026-06-10T07:00:00+02:00",
total_interruption_duration: 900, // 15 min awake
light_sleep: 3600, // 60 min
deep_sleep: 1800, // 30 min
rem_sleep: 5400, // 90 min
sleep_score: 82,
};
const mapped = mapSleep(s);
const onset = Date.parse(start);
// AWAKE is laid first (leading settling-in block), so the asleep stages
// partition the rest of the window and the night reader can surface real
// awake time + efficiency rather than a fully consolidated night.
const awake = mapped.find((m) => m.sleepStage === "AWAKE")!;
expect(awake).toBeDefined();
expect(awake.value).toBe(15);
expect(awake.reconstructed).toBe(true);
expect(awake.externalId).toBe("sleep:2026-06-10:seg:sleep_awake:0");
// AWAKE ends at +15m; CORE then starts there and is index 1.
expect(awake.measuredAt.getTime()).toBe(onset + 15 * 60_000);
const core = mapped.find((m) => m.sleepStage === "CORE")!;
expect(core.externalId).toBe("sleep:2026-06-10:seg:sleep_core:1");
expect(core.measuredAt.getTime()).toBe(onset + (15 + 60) * 60_000);
});
it("omits AWAKE when no interruption time is reported", () => {
const mapped = mapSleep({
date: "2026-06-10",
sleep_start_time: "2026-06-09T23:00:00+02:00",
sleep_end_time: "2026-06-10T07:00:00+02:00",
light_sleep: 3600,
deep_sleep: 1800,
rem_sleep: 5400,
});
expect(mapped.find((m) => m.sleepStage === "AWAKE")).toBeUndefined();
// CORE stays index 0 when AWAKE is absent.
expect(mapped.find((m) => m.sleepStage === "CORE")!.externalId).toBe(
"sleep:2026-06-10:seg:sleep_core:0",
);
});
it("falls back to a midnight-UTC anchor when the window is missing", () => {
const mapped = mapSleep({
date: "2026-06-10",
+33 -36
View File
@@ -22,6 +22,7 @@
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { PolarApiError, classifyPolarResponse } from "./response-classifier";
const POLAR_API_BASE = "https://www.polaraccesslink.com";
@@ -481,51 +482,47 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] {
Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs;
const stages: Array<
[number | null | undefined, NonNullable<MappedMeasurement["sleepStage"]>, string]
[number | null | undefined, "CORE" | "DEEP" | "REM" | "AWAKE", string]
> = [
// AWAKE first — Polar reports the night's total interruption time, but no
// per-stage onset. Lay it as a leading settling-in/awake block so the asleep
// stages partition the remaining window and the night reader can surface a
// real awakeMinutes / efficiency rather than reading the night as fully
// consolidated. Matches WHOOP's leading-AWAKE ordering.
[s.total_interruption_duration, "AWAKE", "sleep_awake"],
[s.light_sleep, "CORE", "sleep_core"],
[s.deep_sleep, "DEEP", "sleep_deep"],
[s.rem_sleep, "REM", "sleep_rem"],
];
if (haveWindow) {
// Reconstructed timeline: lay each asleep stage contiguously from onset, one
// timed row per segment ending at the running cursor. The order is
// synthetic (Polar gives no per-stage onset), so flag every segment.
let cursor = startMs;
let segIndex = 0;
for (const [sec, stage, fieldTag] of stages) {
if (typeof sec !== "number" || sec <= 0) continue;
const segEnd = cursor + sec * 1000;
out.push({
type: "SLEEP_DURATION",
value: round2(sec * SEC_TO_MIN),
unit: "minutes",
measuredAt: new Date(segEnd),
fieldTag,
// Reconstructed timeline: lay each stage contiguously from onset, one timed
// row per segment ending at the running cursor. The order is synthetic
// (Polar gives no per-stage onset), so the shared builder flags every
// segment reconstructed; the same algorithm backs WHOOP's `mapSleep`.
out.push(
...reconstructContiguousSleepTimeline({
startMs,
stages: stages.map(([sec, stage, fieldTag]) => ({
durationMs:
typeof sec === "number" && Number.isFinite(sec) ? sec * 1000 : sec,
stage,
fieldTag,
})),
// IN_BED — single envelope row over the whole sleep window, stamped at
// the sleep END so the in-bed reader resolves the span back to
// [start, end].
inBed: {
durationMs: endMs - startMs,
measuredAt: new Date(endMs),
fieldTag: "sleep_in_bed",
},
// Indexed externalId keeps the several segment rows of one night
// distinct under userId_type_source_externalId.
externalId: `sleep:${s.date}:seg:${fieldTag}:${segIndex}`,
sleepStage: stage,
reconstructed: true,
});
cursor = segEnd;
segIndex += 1;
}
// IN_BED — single envelope row over the whole sleep window, stamped at the
// sleep END so the in-bed reader resolves the span back to [start, end].
const inBedSec = (endMs - startMs) / 1000;
if (inBedSec > 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(inBedSec * SEC_TO_MIN),
unit: "minutes",
measuredAt: new Date(endMs),
fieldTag: "sleep_in_bed",
sleepStage: "IN_BED",
});
}
externalIdFor: (fieldTag, index) =>
`sleep:${s.date}:seg:${fieldTag}:${index}`,
}),
);
if (typeof s.sleep_score === "number") {
out.push({
@@ -0,0 +1,90 @@
import { describe, it, expect } from "vitest";
import { reconstructContiguousSleepTimeline } from "../reconstruct-timeline";
describe("reconstructContiguousSleepTimeline", () => {
const onset = Date.parse("2026-06-09T21:00:00.000Z");
const end = Date.parse("2026-06-10T05:00:00.000Z");
it("lays stages contiguously from onset, each ending at its own instant", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
{ durationMs: 30 * 60_000, stage: "DEEP", fieldTag: "sleep_deep" },
{ durationMs: 90 * 60_000, stage: "REM", fieldTag: "sleep_rem" },
],
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
const core = rows.find((r) => r.sleepStage === "CORE")!;
const deep = rows.find((r) => r.sleepStage === "DEEP")!;
const rem = rows.find((r) => r.sleepStage === "REM")!;
expect(core.value).toBe(60);
expect(core.measuredAt.getTime()).toBe(onset + 60 * 60_000);
expect(deep.measuredAt.getTime()).toBe(onset + 90 * 60_000);
expect(rem.measuredAt.getTime()).toBe(onset + 180 * 60_000);
// Distinct instants → ordered hypnogram, not a shared right edge.
expect(new Set(rows.map((r) => r.measuredAt.getTime())).size).toBe(3);
});
it("flags every laid segment reconstructed and keys an indexed externalId", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [
{ durationMs: 15 * 60_000, stage: "AWAKE", fieldTag: "sleep_awake" },
{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
],
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
expect(rows.every((r) => r.reconstructed === true)).toBe(true);
expect(rows[0].externalId).toBe("night:seg:sleep_awake:0");
expect(rows[1].externalId).toBe("night:seg:sleep_core:1");
});
it("skips non-positive, null, undefined and non-finite durations", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [
{ durationMs: 0, stage: "AWAKE", fieldTag: "sleep_awake" },
{ durationMs: null, stage: "CORE", fieldTag: "sleep_core" },
{ durationMs: undefined, stage: "DEEP", fieldTag: "sleep_deep" },
{ durationMs: Number.NaN, stage: "REM", fieldTag: "sleep_rem" },
{ durationMs: 30 * 60_000, stage: "CORE", fieldTag: "sleep_core" },
],
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
expect(rows).toHaveLength(1);
// The only laid stage keeps index 0 (skipped stages do not consume indices).
expect(rows[0].externalId).toBe("night:seg:sleep_core:0");
expect(rows[0].measuredAt.getTime()).toBe(onset + 30 * 60_000);
});
it("appends a single IN_BED envelope at the given END instant", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }],
inBed: {
durationMs: end - onset,
measuredAt: new Date(end),
fieldTag: "sleep_in_bed",
},
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
const inBed = rows.find((r) => r.sleepStage === "IN_BED")!;
expect(inBed.measuredAt.getTime()).toBe(end);
expect(inBed.value).toBe(480); // 8 h
// IN_BED is an envelope, not a placed segment — no reconstructed flag / id.
expect(inBed.reconstructed).toBeUndefined();
expect(inBed.externalId).toBeUndefined();
});
it("omits IN_BED when its duration is missing or non-positive", () => {
const rows = reconstructContiguousSleepTimeline({
startMs: onset,
stages: [{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }],
inBed: { durationMs: 0, measuredAt: new Date(end), fieldTag: "sleep_in_bed" },
externalIdFor: (tag, i) => `night:seg:${tag}:${i}`,
});
expect(rows.find((r) => r.sleepStage === "IN_BED")).toBeUndefined();
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Shared reconstructed-sleep-timeline builder.
*
* Some wearables (WHOOP v2, Polar) expose only per-stage DURATION totals for a
* night — no per-stage onset timestamps and no hypnogram endpoint. Stamping
* every stage total on the one sleep-END instant collapses the hypnogram into
* overlapping spans that all touch the night's right edge. Since the API never
* carries an order, both vendors RECONSTRUCT an ordered, contiguous timeline:
* lay the stages back-to-back from sleep ONSET in a fixed physiological order,
* emitting one timed row per segment with `measuredAt = that segment's END`.
*
* The ORDER is synthetic, so every laid segment is flagged `reconstructed:
* true`; the night DTO advertises it and the UI labels such a night as an
* approximate layout, never as measured stage timing. The reader's honesty
* flag (`analytics/sleep-night.ts` `RECONSTRUCTED_TIMELINE_SOURCES`) carries
* the same contract on the read side.
*
* This helper is the single source of the algorithm; each vendor `mapSleep`
* keeps only its field-name → tuple normalisation (ms vs sec, source field
* names) so the segment shape and externalId scheme can never drift between the
* two synthetic-timeline vendors. Oura's measured 5-min hypnogram is a
* different algorithm and does NOT use this helper.
*/
/** The sleep-row shape both vendor mappers emit. Kept structurally identical to
* each vendor's local `MappedMeasurement`; the helper returns rows assignable to
* either. */
export interface ReconstructedSleepRow {
type: "SLEEP_DURATION";
value: number;
unit: "minutes";
measuredAt: Date;
fieldTag: string;
externalId?: string;
sleepStage: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED";
reconstructed?: boolean;
}
/** One asleep/awake stage to lay onto the reconstructed timeline. `durationMs`
* is the stage total in milliseconds; non-positive or non-finite durations are
* skipped. */
export interface ReconstructedStage {
durationMs: number | null | undefined;
stage: "CORE" | "DEEP" | "REM" | "AWAKE";
fieldTag: string;
}
/** Optional single IN_BED envelope row over the whole sleep window. The in-bed
* reader consumes the union envelope, so this stays one row stamped at the
* sleep END (`measuredAt`) — NOT a placed segment. */
export interface ReconstructedInBed {
durationMs: number | null | undefined;
/** Sleep END instant; the in-bed reader resolves the span back to its window. */
measuredAt: Date;
fieldTag: string;
}
export interface ReconstructTimelineOptions {
/** Sleep ONSET instant; the contiguous walk starts here. */
startMs: number;
/** Stages in physiological lay-out order (e.g. AWAKE → CORE → DEEP → REM). */
stages: ReadonlyArray<ReconstructedStage>;
/** Optional IN_BED envelope row. */
inBed?: ReconstructedInBed;
/**
* Builds the indexed externalId for a laid segment so the several rows of one
* night stay distinct under `userId_type_source_externalId`. Called with the
* segment's fieldTag and its running index.
*/
externalIdFor: (fieldTag: string, index: number) => string;
}
const MS_TO_MIN = 1 / 60_000;
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* Lay the asleep/awake stages contiguously from `startMs` in the given order,
* emitting one timed `SLEEP_DURATION` row per stage (`measuredAt` = that
* segment's END), each flagged `reconstructed: true` and keyed by an indexed
* externalId. Appends a single IN_BED envelope row when `inBed` is provided.
*/
export function reconstructContiguousSleepTimeline(
opts: ReconstructTimelineOptions,
): ReconstructedSleepRow[] {
const { startMs, stages, inBed, externalIdFor } = opts;
const out: ReconstructedSleepRow[] = [];
let cursor = startMs;
let segIndex = 0;
for (const { durationMs, stage, fieldTag } of stages) {
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) {
continue;
}
const segEnd = cursor + durationMs;
out.push({
type: "SLEEP_DURATION",
value: round2(durationMs * MS_TO_MIN),
unit: "minutes",
measuredAt: new Date(segEnd),
fieldTag,
externalId: externalIdFor(fieldTag, segIndex),
sleepStage: stage,
reconstructed: true,
});
cursor = segEnd;
segIndex += 1;
}
if (
inBed &&
typeof inBed.durationMs === "number" &&
Number.isFinite(inBed.durationMs) &&
inBed.durationMs > 0
) {
out.push({
type: "SLEEP_DURATION",
value: round2(inBed.durationMs * MS_TO_MIN),
unit: "minutes",
measuredAt: inBed.measuredAt,
fieldTag: inBed.fieldTag,
sleepStage: "IN_BED",
});
}
return out;
}
+30 -46
View File
@@ -17,6 +17,7 @@
*/
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
import { WhoopApiError, classifyWhoopResponse } from "./response-classifier";
const WHOOP_API_BASE = "https://api.prod.whoop.com/developer";
@@ -611,55 +612,38 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
// the remaining window.
const onset = new Date(s.start).getTime();
// Order the laid-out stages: a brief AWAKE settling-in block, then the asleep
// stages CORE → DEEP → REM. Each carries its own duration; the cursor walks
// forward so the spans are contiguous and non-overlapping.
const TIMELINE_ORDER: ReadonlyArray<{
key: keyof typeof stages;
stage: NonNullable<MappedMeasurement["sleepStage"]>;
tag: string;
}> = [
{ key: "total_awake_time_milli", stage: "AWAKE", tag: "sleep_awake" },
{ key: "total_light_sleep_time_milli", stage: "CORE", tag: "sleep_core" },
{ key: "total_slow_wave_sleep_time_milli", stage: "DEEP", tag: "sleep_deep" },
{ key: "total_rem_sleep_time_milli", stage: "REM", tag: "sleep_rem" },
];
let cursor = onset;
let segIndex = 0;
for (const { key, stage, tag } of TIMELINE_ORDER) {
const ms = stages[key];
if (typeof ms !== "number" || ms <= 0) continue;
const segEnd = cursor + ms;
out.push({
type: "SLEEP_DURATION",
value: round2(ms * MS_TO_MIN),
unit: "minutes",
measuredAt: new Date(segEnd),
// stages CORE → DEEP → REM. Each carries its own duration; the shared builder
// walks the cursor forward so the spans are contiguous and non-overlapping,
// and lays the IN_BED envelope over the whole window. The order is synthetic
// (WHOOP gives no per-stage onset), so every laid segment is flagged
// reconstructed; the same algorithm backs Polar's `mapSleep`.
out.push(
...reconstructContiguousSleepTimeline({
startMs: onset,
stages: [
{ durationMs: stages.total_awake_time_milli, stage: "AWAKE", fieldTag: "sleep_awake" },
{ durationMs: stages.total_light_sleep_time_milli, stage: "CORE", fieldTag: "sleep_core" },
{
durationMs: stages.total_slow_wave_sleep_time_milli,
stage: "DEEP",
fieldTag: "sleep_deep",
},
{ durationMs: stages.total_rem_sleep_time_milli, stage: "REM", fieldTag: "sleep_rem" },
],
// IN_BED — single envelope row over the whole sleep window. `measuredAt`
// is the sleep END so `segmentOf` resolves the span back to [start, end];
// the in-bed union-envelope reader consumes the full window.
inBed: {
durationMs: stages.total_in_bed_time_milli,
measuredAt,
fieldTag: "sleep_in_bed",
},
// The sync layer keys reconstructed rows by this externalId verbatim; the
// index keeps the several rows of one night distinct under
// userId_type_source_externalId.
fieldTag: tag,
externalId: `${s.id}:seg:${tag}:${segIndex}`,
sleepStage: stage,
reconstructed: true,
});
cursor = segEnd;
segIndex += 1;
}
// IN_BED — single envelope row over the whole sleep window. `measuredAt` is
// the sleep END so `segmentOf` resolves the span back to [start, end]; the
// in-bed union-envelope reader consumes the full window.
const inBedMs = stages.total_in_bed_time_milli;
if (typeof inBedMs === "number" && inBedMs > 0) {
out.push({
type: "SLEEP_DURATION",
value: round2(inBedMs * MS_TO_MIN),
unit: "minutes",
measuredAt,
fieldTag: "sleep_in_bed",
sleepStage: "IN_BED",
});
}
externalIdFor: (tag, index) => `${s.id}:seg:${tag}:${index}`,
}),
);
const need = s.score.sleep_needed;
const totalNeedMilli =