feat(integrations): add Nightscout CGM glucose sync

Pull continuous glucose readings from a self-hosted Nightscout instance
and store them as immutable BLOOD_GLUCOSE mg/dL measurements
(source=NIGHTSCOUT), giving the glucose panel real CGM density.

- client over safeFetch: public-host pin by default, relaxed only on the
  per-user private-network opt-in; redirect pinned manual + timeout
- credentials encrypted at rest on User; URL is the connect marker
- sync maps SGV entries first-write-wins on (userId,type,source,externalId)
- connect/disconnect/status routes: apiHandler + requireAuth + rate limit;
  connect validates by a live test fetch with clear errors
- hourly poll-cohort job registered in the all-queues registry
- settings card with URL, token, and the private-network toggle
- i18n across all six locales
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 19:06:56 +02:00
1 parent 4884f019b7
commit f5d0b2f696
23 files changed
+1803 -9

No files matched your search

+20 -1
View File
@@ -4470,7 +4470,26 @@
"enable": "Zyklus-Tracking aktivieren",
"manage": "Zyklus-Tracking öffnen",
"error": "Zyklus-Tracking konnte nicht aktualisiert werden."
}
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte für das Glukose-Panel zu synchronisieren.",
"nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.",
"nightscoutUrl": "Nightscout-URL",
"nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen",
"nightscoutToken": "API-Token",
"nightscoutTokenOptional": "Optional bei einer öffentlichen Seite",
"nightscoutTokenSavedPlaceholder": "Gespeichert — neuen Token eingeben zum Ersetzen",
"nightscoutTokenHelp": "Füge einen Zugriffstoken aus deinen Nightscout-Admin-Tools ein. Leer lassen, wenn deine Seite öffentlich lesbar ist.",
"nightscoutPrivateHost": "Diese Seite läuft in einem privaten Netzwerk",
"nightscoutPrivateHostHelp": "Nur aktivieren, wenn deine Nightscout-Instanz unter einer LAN-, VPN- oder Tailscale-Adresse läuft. Aus bei öffentlichen Seiten.",
"nightscoutConnect": "Verbinden",
"nightscoutUpdate": "Änderungen speichern",
"nightscoutConnected": "Nightscout verbunden.",
"nightscoutConnectFailed": "Verbindung zu Nightscout fehlgeschlagen.",
"nightscoutDisconnect": "Trennen",
"nightscoutDisconnectTitle": "Nightscout trennen?",
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf."
},
"admin": {
"title": "Administration",
+20 -1
View File
@@ -4470,7 +4470,26 @@
"enable": "Enable cycle tracking",
"manage": "Open cycle tracking",
"error": "Couldn't update cycle tracking."
}
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings for the glucose panel.",
"nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.",
"nightscoutUrl": "Nightscout URL",
"nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it",
"nightscoutToken": "API token",
"nightscoutTokenOptional": "Optional for a public site",
"nightscoutTokenSavedPlaceholder": "Saved — enter a new token to replace it",
"nightscoutTokenHelp": "Paste an access token from your Nightscout admin tools. Leave empty if your site is publicly readable.",
"nightscoutPrivateHost": "This site runs on a private network",
"nightscoutPrivateHostHelp": "Enable only if your Nightscout instance is on a LAN, VPN, or Tailscale address. Off for public sites.",
"nightscoutConnect": "Connect",
"nightscoutUpdate": "Save changes",
"nightscoutConnected": "Nightscout connected.",
"nightscoutConnectFailed": "Could not connect to Nightscout.",
"nightscoutDisconnect": "Disconnect",
"nightscoutDisconnectTitle": "Disconnect Nightscout?",
"nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history."
},
"admin": {
"title": "Administration",
+20 -1
View File
@@ -4470,7 +4470,26 @@
"enable": "Activar el seguimiento del ciclo",
"manage": "Abrir seguimiento del ciclo",
"error": "No se pudo actualizar el seguimiento del ciclo."
}
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.",
"nightscoutHelp": "Apunta HealthLog a tu instancia de Nightscout. Las lecturas se obtienen en segundo plano una vez por hora.",
"nightscoutUrl": "URL de Nightscout",
"nightscoutUrlSavedPlaceholder": "Guardado: introduce una URL nueva para reemplazarla",
"nightscoutToken": "Token de API",
"nightscoutTokenOptional": "Opcional para un sitio público",
"nightscoutTokenSavedPlaceholder": "Guardado: introduce un token nuevo para reemplazarlo",
"nightscoutTokenHelp": "Pega un token de acceso de las herramientas de administración de Nightscout. Déjalo vacío si tu sitio es de lectura pública.",
"nightscoutPrivateHost": "Este sitio funciona en una red privada",
"nightscoutPrivateHostHelp": "Actívalo solo si tu instancia de Nightscout está en una dirección LAN, VPN o Tailscale. Desactivado para sitios públicos.",
"nightscoutConnect": "Conectar",
"nightscoutUpdate": "Guardar cambios",
"nightscoutConnected": "Nightscout conectado.",
"nightscoutConnectFailed": "No se pudo conectar con Nightscout.",
"nightscoutDisconnect": "Desconectar",
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial."
},
"admin": {
"title": "Administration",
+20 -1
View File
@@ -4470,7 +4470,26 @@
"enable": "Activer le suivi du cycle",
"manage": "Ouvrir le suivi du cycle",
"error": "Impossible de mettre à jour le suivi du cycle."
}
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Connectez votre propre site Nightscout pour synchroniser les mesures de glycémie en continu dans le panneau glucose.",
"nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.",
"nightscoutUrl": "URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer",
"nightscoutToken": "Jeton d'API",
"nightscoutTokenOptional": "Facultatif pour un site public",
"nightscoutTokenSavedPlaceholder": "Enregistré — saisissez un nouveau jeton pour le remplacer",
"nightscoutTokenHelp": "Collez un jeton d'accès depuis les outils d'administration de Nightscout. Laissez vide si votre site est lisible publiquement.",
"nightscoutPrivateHost": "Ce site fonctionne sur un réseau privé",
"nightscoutPrivateHostHelp": "Activez uniquement si votre instance Nightscout est sur une adresse LAN, VPN ou Tailscale. Désactivé pour les sites publics.",
"nightscoutConnect": "Connecter",
"nightscoutUpdate": "Enregistrer les modifications",
"nightscoutConnected": "Nightscout connecté.",
"nightscoutConnectFailed": "Impossible de se connecter à Nightscout.",
"nightscoutDisconnect": "Déconnecter",
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique."
},
"admin": {
"title": "Administration",
+20 -1
View File
@@ -4470,7 +4470,26 @@
"enable": "Attiva il monitoraggio del ciclo",
"manage": "Apri il monitoraggio del ciclo",
"error": "Impossibile aggiornare il monitoraggio del ciclo."
}
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue della glicemia nel pannello glicemia.",
"nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.",
"nightscoutUrl": "URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo",
"nightscoutToken": "Token API",
"nightscoutTokenOptional": "Facoltativo per un sito pubblico",
"nightscoutTokenSavedPlaceholder": "Salvato — inserisci un nuovo token per sostituirlo",
"nightscoutTokenHelp": "Incolla un token di accesso dagli strumenti di amministrazione di Nightscout. Lascia vuoto se il tuo sito è leggibile pubblicamente.",
"nightscoutPrivateHost": "Questo sito è su una rete privata",
"nightscoutPrivateHostHelp": "Attiva solo se la tua istanza Nightscout è su un indirizzo LAN, VPN o Tailscale. Disattivato per i siti pubblici.",
"nightscoutConnect": "Connetti",
"nightscoutUpdate": "Salva modifiche",
"nightscoutConnected": "Nightscout connesso.",
"nightscoutConnectFailed": "Impossibile connettersi a Nightscout.",
"nightscoutDisconnect": "Disconnetti",
"nightscoutDisconnectTitle": "Disconnettere Nightscout?",
"nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia."
},
"admin": {
"title": "Administration",
+20 -1
View File
@@ -4470,7 +4470,26 @@
"enable": "Włącz śledzenie cyklu",
"manage": "Otwórz śledzenie cyklu",
"error": "Nie udało się zaktualizować śledzenia cyklu."
}
},
"nightscout": "Nightscout",
"nightscoutTag": "CGM",
"nightscoutDescription": "Połącz własną witrynę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
"nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.",
"nightscoutUrl": "Adres URL Nightscout",
"nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić",
"nightscoutToken": "Token API",
"nightscoutTokenOptional": "Opcjonalny dla witryny publicznej",
"nightscoutTokenSavedPlaceholder": "Zapisano — wpisz nowy token, aby go zastąpić",
"nightscoutTokenHelp": "Wklej token dostępu z narzędzi administracyjnych Nightscout. Pozostaw puste, jeśli witryna jest publicznie czytelna.",
"nightscoutPrivateHost": "Ta witryna działa w sieci prywatnej",
"nightscoutPrivateHostHelp": "Włącz tylko, jeśli instancja Nightscout znajduje się pod adresem LAN, VPN lub Tailscale. Wyłączone dla witryn publicznych.",
"nightscoutConnect": "Połącz",
"nightscoutUpdate": "Zapisz zmiany",
"nightscoutConnected": "Nightscout połączony.",
"nightscoutConnectFailed": "Nie można połączyć z Nightscout.",
"nightscoutDisconnect": "Rozłącz",
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii."
},
"admin": {
"title": "Administracja",
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { SafeFetchError } from "@/lib/safe-fetch";
import { NightscoutApiError } from "@/lib/nightscout/client";
const { fetchSgvEntriesMock, rateLimitMock } = vi.hoisted(() => ({
fetchSgvEntriesMock: vi.fn(),
rateLimitMock: vi.fn(),
}));
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: { update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() }));
vi.mock("@/lib/nightscout/client", async (importOriginal) => {
const actual = await importOriginal<
typeof import("@/lib/nightscout/client")
>();
return { ...actual, fetchSgvEntries: fetchSgvEntriesMock };
});
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: rateLimitMock,
rateLimitHeaders: () => ({}),
}));
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 { POST } from "../route";
import { prisma } from "@/lib/db";
import { markReconnected } 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/nightscout/connect", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
type RouteResult = { data: unknown; error: string | null; status: number };
const post = POST as unknown as (r: NextRequest) => Promise<RouteResult>;
beforeEach(() => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 });
fetchSgvEntriesMock.mockResolvedValue([{ id: "x", sgv: 100, date: 1 }]);
});
describe("POST /api/nightscout/connect", () => {
it("422s on a missing / malformed URL", async () => {
const res = await post(req({ url: "not a url" }));
expect(res.status).toBe(422);
expect(userUpdate).not.toHaveBeenCalled();
});
it("validates via a test fetch and stores encrypted creds on success", async () => {
const res = await post(
req({ url: "https://ns.example.com", token: "tok" }),
);
expect(res.status).toBe(200);
expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1);
const fetchArg = fetchSgvEntriesMock.mock.calls[0]![0];
expect(fetchArg.count).toBe(1);
expect(fetchArg.allowPrivateHost).toBe(false);
const data = userUpdate.mock.calls[0]![0].data;
expect(data.nightscoutUrlEncrypted).toBe("enc:https://ns.example.com");
expect(data.nightscoutTokenEncrypted).toBe("enc:tok");
expect(data.nightscoutAllowPrivateHost).toBe(false);
expect(markReconnected).toHaveBeenCalledWith("u1", "nightscout");
});
it("stores a null token for a public, token-less instance", async () => {
await post(req({ url: "https://ns.example.com" }));
const data = userUpdate.mock.calls[0]![0].data;
expect(data.nightscoutTokenEncrypted).toBeNull();
});
it("passes the private-host opt-in through to the test fetch", async () => {
await post(
req({
url: "http://192.168.1.5:1337",
token: "tok",
allowPrivateHost: true,
}),
);
const fetchArg = fetchSgvEntriesMock.mock.calls[0]![0];
expect(fetchArg.allowPrivateHost).toBe(true);
});
it("refuses a private host when the opt-in is off (clear 422, no store)", async () => {
fetchSgvEntriesMock.mockRejectedValue(
new SafeFetchError("refused", "private_host"),
);
const res = await post(req({ url: "http://10.0.0.5", token: "tok" }));
expect(res.status).toBe(422);
expect(String(res.error)).toMatch(/private network/i);
expect(userUpdate).not.toHaveBeenCalled();
});
it("surfaces a wrong-token rejection as a clear 422", async () => {
fetchSgvEntriesMock.mockRejectedValue(
new NightscoutApiError("responded 401", 401),
);
const res = await post(req({ url: "https://ns.example.com", token: "x" }));
expect(res.status).toBe(422);
expect(String(res.error)).toMatch(/token/i);
expect(userUpdate).not.toHaveBeenCalled();
});
it("surfaces an unreachable instance as a clear 422", async () => {
fetchSgvEntriesMock.mockRejectedValue(
new SafeFetchError("timeout", "timeout"),
);
const res = await post(req({ url: "https://ns.example.com" }));
expect(res.status).toBe(422);
expect(String(res.error)).toMatch(/reach/i);
expect(userUpdate).not.toHaveBeenCalled();
});
it("rate-limits the connect surface", async () => {
rateLimitMock.mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: 0,
});
const res = await post(req({ url: "https://ns.example.com" }));
expect(res.status).toBe(429);
expect(fetchSgvEntriesMock).not.toHaveBeenCalled();
});
});
+103
View File
@@ -0,0 +1,103 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
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 { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markReconnected } from "@/lib/integrations/status";
import { SafeFetchError } from "@/lib/safe-fetch";
import {
fetchSgvEntries,
NightscoutApiError,
} from "@/lib/nightscout/client";
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
/**
* Connect (or update) the user's Nightscout instance (v1.17.0).
*
* Validates the URL + token by a live TEST FETCH of one SGV entry before
* storing anything — a wrong token (401/403), an unreachable instance, or a
* private host the user didn't opt into all surface as a clear error here
* rather than silently parking the integration on its first cron tick.
*
* On success the URL + token are encrypted at rest on `User` and the
* `nightscout` integration ledger is reset to connected. Per-user rate-limited
* (a test fetch is an outbound call; throttle the connect surface so a tight
* retry loop can't be used to probe arbitrary hosts).
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "nightscout.connect" } });
const rl = await checkRateLimit(`nightscout-connect:${user.id}`, 10, 60_000);
if (!rl.allowed) {
return apiError("Too many connection attempts", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(nightscoutConnectSchema, body);
if (!result.success) {
return apiError("A valid Nightscout URL is required", 422);
}
const { url, token, allowPrivateHost } = result.data;
// Live validation: pull a single SGV entry. A connection that can't even
// fetch one row is not worth storing.
try {
await fetchSgvEntries({
baseUrl: url,
token,
count: 1,
allowPrivateHost,
});
} catch (err) {
if (err instanceof SafeFetchError && err.kind === "private_host") {
return apiError(
"That instance is on a private network. Enable the private-network option to connect to it.",
422,
);
}
if (err instanceof NightscoutApiError) {
if (err.status === 401 || err.status === 403) {
return apiError(
"Nightscout rejected the token. Check the API token and try again.",
422,
);
}
return apiError(
"Could not reach the Nightscout instance. Check the URL and that it is online.",
422,
);
}
return apiError(
"Could not reach the Nightscout instance. Check the URL and that it is online.",
422,
);
}
await prisma.user.update({
where: { id: user.id },
data: {
// Build the data object field-by-field — never spread the parsed body.
nightscoutUrlEncrypted: encrypt(url),
nightscoutTokenEncrypted: token ? encrypt(token) : null,
nightscoutAllowPrivateHost: allowPrivateHost,
},
});
await markReconnected(user.id, "nightscout");
await auditLog("nightscout.connect", { userId: user.id });
return apiSuccess({ connected: true });
});
@@ -0,0 +1,50 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { markDisconnected } from "@/lib/integrations/status";
/**
* Disconnect the user's Nightscout instance (v1.17.0).
*
* Clears the encrypted URL + token and the private-host flag on `User`, and
* parks the `nightscout` integration ledger at `disconnected`. Already-ingested
* glucose rows are left in place — disconnecting stops future syncs, it does
* not delete the history the panel reads.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "nightscout.disconnect" } });
const rl = await checkRateLimit(`nightscout-disconnect:${user.id}`, 20, 60_000);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { nightscoutUrlEncrypted: true },
});
if (!dbUser?.nightscoutUrlEncrypted) {
return apiError("No Nightscout connection", 404);
}
await prisma.user.update({
where: { id: user.id },
data: {
nightscoutUrlEncrypted: null,
nightscoutTokenEncrypted: null,
nightscoutAllowPrivateHost: false,
},
});
await auditLog("nightscout.disconnect", { userId: user.id });
await markDisconnected(user.id, "nightscout");
return apiSuccess({ disconnected: true });
});
+49
View File
@@ -0,0 +1,49 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* Nightscout connection status for the current user (v1.17.0).
*
* `configured` is whether an instance URL is stored (the connect marker — a
* fully-public instance has no token). The ledger snapshot (state /
* lastSuccessAt / lastError) comes off the shared `nightscout` IntegrationKey
* so the Settings card surfaces the same connected / error / parked pill the
* other integrations use. The stored URL is echoed back (host only is enough
* for the card, but the URL is the user's own and not a secret); the token is
* NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "nightscout.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
nightscoutUrlEncrypted: true,
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
},
});
const configured = !!dbUser?.nightscoutUrlEncrypted;
if (!configured) {
return apiSuccess({ connected: false, configured: false });
}
const status = await getIntegrationStatus(user.id, "nightscout");
return apiSuccess({
connected: true,
configured: true,
hasToken: !!dbUser?.nightscoutTokenEncrypted,
allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
});
});
@@ -131,9 +131,9 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// Exactly one pill per card → 3 pills total (Withings, WHOOP,
// Fitbit / Google Health). The moodLog integration was removed.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(3);
// Exactly one pill per card → 4 pills total (Withings, WHOOP,
// Fitbit / Google Health, Nightscout). The moodLog integration was removed.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(4);
// The redundant banner from v1.4.15 is gone.
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
@@ -7,6 +7,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import {
pickStatus,
useIntegrationStatuses,
@@ -124,6 +125,7 @@ export function IntegrationsSection() {
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
<NightscoutCard enabled={isAuthenticated} />
</section>
);
}
@@ -0,0 +1,301 @@
"use client";
// v1.17.0 — Nightscout CGM integration card. Unlike WHOOP / Fitbit (OAuth,
// BYO-key), Nightscout is a URL + token the self-hoster pastes once: the user
// runs their own instance and HealthLog pulls continuous glucose off it. The
// card has its own status read (`/api/nightscout/status`) rather than the
// consolidated envelope. Warm copy, mobile-first, the private-network opt-in
// toggle maps to `nightscoutAllowPrivateHost`.
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Link2, Loader2, Save, Unlink } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys, queryKeys } from "@/lib/query-keys";
import { IntegrationErrorMessage } from "./shared";
interface NightscoutStatus {
connected: boolean;
configured: boolean;
hasToken?: boolean;
allowPrivateHost?: boolean;
state?: "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
}
/** Map the shared ledger state onto the pill's display state. */
function pillStateFor(status: NightscoutStatus | undefined): IntegrationPillState {
if (!status?.connected) return "disconnected";
switch (status.state) {
case "parked":
return "parked";
case "error_reauth":
return "error";
case "error_transient":
return "warning";
default:
return "connected";
}
}
export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const [allowPrivateHost, setAllowPrivateHost] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const { data: status } = useQuery({
queryKey: queryKeys.nightscoutStatus(),
queryFn: async () => apiGet<NightscoutStatus>("/api/nightscout/status"),
enabled,
refetchOnWindowFocus: true,
});
const disconnect = useMutation({
mutationFn: async () => {
await apiPost("/api/nightscout/disconnect");
},
onSuccess: () => {
setUrl("");
setToken("");
setAllowPrivateHost(false);
queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() });
},
});
async function handleConnect(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
try {
const res = await apiFetchRaw("/api/nightscout/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: url.trim(),
token: token.trim(),
allowPrivateHost,
}),
});
if (res.ok) {
setMsg(t("settings.nightscoutConnected"));
setMsgType("success");
setToken("");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() });
} else {
let detail = t("settings.nightscoutConnectFailed");
try {
const json = await res.json();
if (json.error) detail = json.error;
} catch {
// keep the generic message
}
setMsg(detail);
setMsgType("error");
}
} catch {
setMsg(t("common.networkError"));
setMsgType("error");
}
setSaving(false);
}
const pillState = pillStateFor(status);
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
? status.lastError
: null;
return (
<div
data-testid="nightscout-card"
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={Activity}
title={t("settings.nightscout")}
titleAccessory={
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
{t("settings.nightscoutTag")}
</span>
}
description={<p>{t("settings.nightscoutDescription")}</p>}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={status?.lastSuccessAt ?? null}
/>
}
/>
<hr className="border-border/60 mt-4" />
<div className="mt-4 space-y-4 pl-7">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
<p className="text-muted-foreground text-xs">
{t("settings.nightscoutHelp")}
</p>
<form onSubmit={handleConnect} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="nightscout-url">{t("settings.nightscoutUrl")}</Label>
<Input
id="nightscout-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={
status?.configured
? t("settings.nightscoutUrlSavedPlaceholder")
: "https://your-site.up.railway.app"
}
maxLength={2048}
autoComplete="off"
inputMode="url"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="next"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="nightscout-token">
{t("settings.nightscoutToken")}
</Label>
<PasswordInput
id="nightscout-token"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder={
status?.hasToken
? t("settings.nightscoutTokenSavedPlaceholder")
: t("settings.nightscoutTokenOptional")
}
maxLength={512}
autoComplete="off"
spellCheck={false}
autoCapitalize="none"
enterKeyHint="done"
/>
<p className="text-muted-foreground/80 text-xs">
{t("settings.nightscoutTokenHelp")}
</p>
</div>
<div className="border-border/60 flex items-start justify-between gap-3 rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="nightscout-private" className="text-sm">
{t("settings.nightscoutPrivateHost")}
</Label>
<p className="text-muted-foreground/80 text-xs">
{t("settings.nightscoutPrivateHostHelp")}
</p>
</div>
<Switch
id="nightscout-private"
checked={allowPrivateHost}
onCheckedChange={setAllowPrivateHost}
/>
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={saving || !url.trim()}
>
{saving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : status?.connected ? (
<Save className="h-3.5 w-3.5" />
) : (
<Link2 className="h-3.5 w-3.5" />
)}
{status?.connected
? t("settings.nightscoutUpdate")
: t("settings.nightscoutConnect")}
</Button>
</div>
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
)}
</form>
{status?.connected && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
>
<Unlink className="h-3.5 w-3.5" />
{t("settings.nightscoutDisconnect")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.nightscoutDisconnectTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.nightscoutDisconnectDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.nightscoutDisconnect")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
);
}
@@ -0,0 +1,47 @@
/**
* v1.17.0 — pg-boss queue registration for the Nightscout CGM sync layer.
*
* Source-as-text guard (the reminder-worker pulls heavy infra we don't boot
* here): assert the queue constant + cron + handler wiring + allQueues
* membership are present — the fast guard against the v1.4.37 dead-queue class
* (a queue declared but never registered in `allQueues`, which silently never
* drains).
*
* Nightscout is poll-only (no webhook, no OAuth, no backfill queue): a single
* hourly `nightscout-sync` tick walks every configured instance via
* `syncUserNightscout`.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const source =
readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") +
readFileSync(join(__dirname, "..", "reminder", "nightscout-sync.ts"), "utf8");
describe("reminder-worker — Nightscout sync queue", () => {
it("declares the nightscout-sync queue constant", () => {
expect(source).toMatch(
/NIGHTSCOUT_SYNC_QUEUE\s*=\s*["']nightscout-sync["']/,
);
});
it("registers the queue in allQueues (v1.4.37 dead-queue guard)", () => {
const block = /const allQueues = \[([\s\S]*?)\];/.exec(source);
expect(block).not.toBeNull();
expect(block![1]!).toContain("NIGHTSCOUT_SYNC_QUEUE");
});
it("schedules the hourly poll cron", () => {
expect(source).toMatch(
/\[NIGHTSCOUT_SYNC_QUEUE,\s*NIGHTSCOUT_SYNC_CRON\]/,
);
});
it("registers a boss.work handler for the queue", () => {
expect(source).toMatch(
/boss\.work[\s\S]{0,160}NIGHTSCOUT_SYNC_QUEUE[\s\S]{0,160}handleNightscoutSync/,
);
});
});
+27
View File
@@ -259,6 +259,10 @@ import {
FitbitOAuthStateCleanupPayload,
handleFitbitOAuthStateCleanup,
} from "./reminder/fitbit-sync";
import {
NightscoutSyncPayload,
handleNightscoutSync,
} from "./reminder/nightscout-sync";
import {
handleMedicationInventoryExpire,
handleIntakeAutoSkip,
@@ -390,6 +394,13 @@ const FITBIT_SYNC_CRON = "8 * * * *"; // every hour at :08
const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup";
const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *";
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook): one hourly tick
// pulls the recent SGV window per configured instance. :11 staggers off the
// WHOOP (:05), Fitbit (:08), and Withings sync ticks so the hourly polls don't
// pile up on one boss poll.
const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync";
const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11
// v1.15.19 — daily duplicate dose-slot dedup discovery tick. The boot-time
// pass only ran on worker restart, so a cross-source duplicate slot created
// between deploys (a pending REMINDER row plus a standalone API/WEB row on
@@ -606,6 +617,11 @@ export async function startReminderWorker() {
FITBIT_SYNC_QUEUE,
FITBIT_BACKFILL_QUEUE,
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no
// backfill queue — the hourly window walks the recent SGV set). The queue
// MUST be registered here or pg-boss never provisions it and the schedule
// below silently no-ops (the v1.4.37 dead-queue class).
NIGHTSCOUT_SYNC_QUEUE,
OFFHOST_BACKUP_QUEUE,
RESTORE_DRILL_QUEUE,
HOST_METRIC_QUEUE,
@@ -826,6 +842,9 @@ export async function startReminderWorker() {
// daily 03:24 Europe/Berlin prune for expired Fitbit OAuth states.
[FITBIT_SYNC_QUEUE, FITBIT_SYNC_CRON],
[FITBIT_OAUTH_STATE_CLEANUP_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_CRON],
// v1.17.0 — hourly Nightscout CGM poll (:11, staggered off the other sync
// ticks).
[NIGHTSCOUT_SYNC_QUEUE, NIGHTSCOUT_SYNC_CRON],
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON],
[RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON],
[HOST_METRIC_QUEUE, HOST_METRIC_CRON],
@@ -1018,6 +1037,14 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleFitbitOAuthStateCleanup,
);
// v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no
// `userId`) walks every configured instance; one user's unreachable host is
// warned, not fatal.
await boss.work<NightscoutSyncPayload>(
NIGHTSCOUT_SYNC_QUEUE,
{ localConcurrency: 1 },
handleNightscoutSync,
);
await boss.work<GeneralStatusPayload>(
GENERAL_STATUS_QUEUE,
{ localConcurrency: 1 },
+70
View File
@@ -0,0 +1,70 @@
/**
* Nightscout hourly poll-cohort sync handler (v1.17.0).
*
* Poll-only: Nightscout has no outbound webhook to HealthLog, so the single
* hourly cron tick carries no `userId` and the handler iterates every user with
* a configured instance, re-syncing each via `syncUserNightscout`. One user's
* unreachable instance never starves the rest of the cohort — a per-user error
* is warned and the pass continues.
*
* Extracted from reminder-worker.ts, which owns the queue name, cron schedule,
* and boss.work registration.
*/
import { type Job } from "pg-boss";
import { recordError } from "@/lib/jobs/worker-status";
import { withBackgroundEvent } from "@/lib/logging/background";
import { syncUserNightscout } from "@/lib/nightscout/sync";
import { getWorkerPrisma } from "./shared";
export interface NightscoutSyncPayload {
userId?: string;
}
export async function handleNightscoutSync(jobs: Job<NightscoutSyncPayload>[]) {
await withBackgroundEvent("job.nightscout_sync", async (evt) => {
const prisma = getWorkerPrisma();
try {
const targets: Array<{ userId: string }> = [];
for (const job of jobs) {
if (job.data?.userId) targets.push({ userId: job.data.userId });
}
if (targets.length === 0) {
// Cron tick — re-walk every configured instance. The URL column is
// the connect marker (a public instance stores no token).
const users = await prisma.user.findMany({
where: { nightscoutUrlEncrypted: { not: null } },
select: { id: true },
});
targets.push(...users.map((u) => ({ userId: u.id })));
}
if (targets.length === 0) return;
let usersSynced = 0;
let measurementsImported = 0;
for (const { userId } of targets) {
try {
measurementsImported += await syncUserNightscout(userId);
usersSynced++;
} catch (err) {
evt.addWarning(
`job.nightscout_sync failed for user ${userId}: ${err}`,
);
}
}
evt.setBackground({
task_name: "job.nightscout_sync",
result: {
users_synced: usersSynced,
total: targets.length,
measurements_imported: measurementsImported,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
}
+219
View File
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SafeFetchError } from "@/lib/safe-fetch";
const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() }));
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return { ...actual, safeFetch: safeFetchMock };
});
import {
buildEntriesUrl,
fetchSgvEntries,
mapSgvEntryToMeasurement,
parseSgvEntries,
sha1Hex,
type NightscoutSgvEntry,
} from "../client";
function jsonResponse(status: number, body: unknown): Response {
return {
status,
ok: status >= 200 && status < 300,
json: async () => body,
} as unknown as Response;
}
const SAMPLE: NightscoutSgvEntry[] = [
{
_id: "abc123",
type: "sgv",
sgv: 112,
date: 1718000000000,
dateString: "2024-06-10T08:53:20.000Z",
},
{
_id: "def456",
type: "sgv",
sgv: 98,
date: 1718000300000,
dateString: "2024-06-10T08:58:20.000Z",
},
];
beforeEach(() => {
safeFetchMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("parseSgvEntries", () => {
it("keeps only SGV rows with a numeric value and timestamp", () => {
const raw = [
...SAMPLE,
{ _id: "mbg1", type: "mbg", mbg: 100, date: 1718000600000 },
{ _id: "cal1", type: "cal", date: 1718000700000 },
{ _id: "bad", type: "sgv", sgv: null, date: 1718000800000 },
{ _id: "nodate", type: "sgv", sgv: 120 },
];
const parsed = parseSgvEntries(raw);
expect(parsed).toHaveLength(2);
expect(parsed[0]!.sgv).toBe(112);
expect(parsed[1]!.sgv).toBe(98);
});
it("returns an empty array for a non-array payload", () => {
expect(parseSgvEntries(null)).toEqual([]);
expect(parseSgvEntries({})).toEqual([]);
expect(parseSgvEntries("nope")).toEqual([]);
});
});
describe("mapSgvEntryToMeasurement", () => {
it("maps an SGV entry to a BLOOD_GLUCOSE mg/dL measurement", () => {
const m = mapSgvEntryToMeasurement(SAMPLE[0]!);
expect(m.type).toBe("BLOOD_GLUCOSE");
expect(m.unit).toBe("mg/dL");
expect(m.value).toBe(112);
expect(m.measuredAt.getTime()).toBe(1718000000000);
});
it("derives a stable externalId so re-sync is idempotent", () => {
const a = mapSgvEntryToMeasurement(SAMPLE[0]!);
const b = mapSgvEntryToMeasurement(SAMPLE[0]!);
expect(a.externalId).toBe(b.externalId);
expect(a.externalId).toContain("abc123");
});
it("falls back to the timestamp when the entry has no _id", () => {
const m = mapSgvEntryToMeasurement({
type: "sgv",
sgv: 90,
date: 1718000000000,
});
expect(m.externalId).toContain("1718000000000");
});
});
describe("buildEntriesUrl", () => {
it("requests SGV entries with the requested count", () => {
const url = buildEntriesUrl("https://ns.example.com", 50);
expect(url).toContain("/api/v1/entries.json");
expect(url).toContain("count=50");
expect(url).toContain("type=sgv");
});
it("normalises a trailing slash on the base URL", () => {
const url = buildEntriesUrl("https://ns.example.com/", 10);
expect(url).not.toContain("com//api");
});
it("appends the token as a query param when provided", () => {
const url = buildEntriesUrl("https://ns.example.com", 10, "tok-abc");
expect(url).toContain("token=tok-abc");
});
});
describe("sha1Hex", () => {
it("hashes the API secret as Nightscout expects (api-secret header)", () => {
// Known SHA1 of "secret".
expect(sha1Hex("secret")).toBe(
"e5e9fa1ba31ecd1ae84f75caaa474f3a663f05f4",
);
});
});
describe("fetchSgvEntries — SSRF gating", () => {
it("requires a public host by default (public instance)", async () => {
safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE));
await fetchSgvEntries({
baseUrl: "https://ns.example.com",
token: "",
count: 10,
allowPrivateHost: false,
});
expect(safeFetchMock).toHaveBeenCalledTimes(1);
const opts = safeFetchMock.mock.calls[0]![2];
expect(opts.requirePublicHost).toBe(true);
});
it("relaxes the public-host pin only when the private-host flag is set", async () => {
safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE));
await fetchSgvEntries({
baseUrl: "http://192.168.1.50:1337",
token: "",
count: 10,
allowPrivateHost: true,
});
const opts = safeFetchMock.mock.calls[0]![2];
expect(opts.requirePublicHost).toBe(false);
});
it("pins redirect:manual + a timeout on every call", async () => {
safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE));
await fetchSgvEntries({
baseUrl: "https://ns.example.com",
token: "",
count: 10,
allowPrivateHost: false,
});
const opts = safeFetchMock.mock.calls[0]![2];
expect(opts.followRedirects).toBeFalsy();
expect(opts.timeoutMs).toBeGreaterThan(0);
});
it("surfaces a private-host rejection from safeFetch", async () => {
safeFetchMock.mockRejectedValue(
new SafeFetchError("refused", "private_host"),
);
await expect(
fetchSgvEntries({
baseUrl: "http://10.0.0.5",
token: "",
count: 10,
allowPrivateHost: false,
}),
).rejects.toThrow();
});
it("sends the api-secret SHA1 header when a token is supplied via header auth", async () => {
safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE));
await fetchSgvEntries({
baseUrl: "https://ns.example.com",
token: "secret",
count: 10,
allowPrivateHost: false,
authMode: "header",
});
const init = safeFetchMock.mock.calls[0]![1];
const headers = init.headers as Record<string, string>;
expect(headers["api-secret"]).toBe(sha1Hex("secret"));
});
it("throws on a non-2xx upstream so the caller can classify", async () => {
safeFetchMock.mockResolvedValue(jsonResponse(401, { status: 401 }));
await expect(
fetchSgvEntries({
baseUrl: "https://ns.example.com",
token: "wrong",
count: 10,
allowPrivateHost: false,
}),
).rejects.toThrow();
});
it("parses SGV rows from a successful response", async () => {
safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE));
const entries = await fetchSgvEntries({
baseUrl: "https://ns.example.com",
token: "",
count: 10,
allowPrivateHost: false,
});
expect(entries).toHaveLength(2);
expect(entries[0]!.sgv).toBe(112);
});
});
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
fetchSgvEntriesMock,
getCredsMock,
upsertMock,
recordSuccessMock,
recordFailureMock,
recomputeMock,
invalidateMock,
} = vi.hoisted(() => ({
fetchSgvEntriesMock: vi.fn(),
getCredsMock: vi.fn(),
upsertMock: vi.fn(),
recordSuccessMock: vi.fn(),
recordFailureMock: vi.fn(),
recomputeMock: vi.fn(),
invalidateMock: vi.fn(),
}));
vi.mock("../credentials", () => ({
getUserNightscoutCredentials: getCredsMock,
}));
vi.mock("@/lib/db", () => ({
prisma: { measurement: { upsert: upsertMock } },
}));
vi.mock("@/lib/integrations/status", () => ({
recordSyncSuccess: recordSuccessMock,
recordSyncFailure: recordFailureMock,
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
collapseToTypeDayKeys: (
rows: Array<{ type: string; measuredAt: Date }>,
) => rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
recomputeBucketsForMeasurement: recomputeMock,
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: invalidateMock,
}));
vi.mock("../client", async (importOriginal) => {
const actual = await importOriginal<typeof import("../client")>();
return { ...actual, fetchSgvEntries: fetchSgvEntriesMock };
});
import { syncUserNightscout } from "../sync";
import { NightscoutApiError } from "../client";
const CREDS = {
baseUrl: "https://ns.example.com",
token: "tok",
allowPrivateHost: false,
};
const ENTRY_A = { id: "abc", sgv: 112, date: 1718000000000 };
const ENTRY_B = { id: "def", sgv: 98, date: 1718000300000 };
beforeEach(() => {
fetchSgvEntriesMock.mockReset();
getCredsMock.mockReset();
upsertMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
recomputeMock.mockReset();
invalidateMock.mockReset();
getCredsMock.mockResolvedValue(CREDS);
upsertMock.mockResolvedValue({});
invalidateMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("syncUserNightscout", () => {
it("returns 0 and records nothing when the user is not configured", async () => {
getCredsMock.mockResolvedValue(null);
const n = await syncUserNightscout("u1");
expect(n).toBe(0);
expect(fetchSgvEntriesMock).not.toHaveBeenCalled();
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("writes each SGV entry as a BLOOD_GLUCOSE mg/dL measurement", async () => {
fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]);
const n = await syncUserNightscout("u1");
expect(n).toBe(2);
expect(upsertMock).toHaveBeenCalledTimes(2);
const first = upsertMock.mock.calls[0]![0];
expect(first.create.type).toBe("BLOOD_GLUCOSE");
expect(first.create.unit).toBe("mg/dL");
expect(first.create.source).toBe("NIGHTSCOUT");
expect(first.create.value).toBe(112);
});
it("keys the upsert on (userId,type,source,externalId) for idempotency", async () => {
fetchSgvEntriesMock.mockResolvedValue([ENTRY_A]);
await syncUserNightscout("u1");
const where = upsertMock.mock.calls[0]![0].where
.userId_type_source_externalId;
expect(where).toEqual({
userId: "u1",
type: "BLOOD_GLUCOSE",
source: "NIGHTSCOUT",
externalId: "ns:abc",
});
});
it("is first-write-wins — the update branch never overwrites value", async () => {
fetchSgvEntriesMock.mockResolvedValue([ENTRY_A]);
await syncUserNightscout("u1");
const arg = upsertMock.mock.calls[0]![0];
// An immutable sample: update must not carry a new value/measuredAt.
expect(arg.update).not.toHaveProperty("value");
});
it("records sync success after a clean pass", async () => {
fetchSgvEntriesMock.mockResolvedValue([ENTRY_A]);
await syncUserNightscout("u1");
expect(recordSuccessMock).toHaveBeenCalledWith("u1", "nightscout");
});
it("records a failure and rethrows when the instance is unreachable", async () => {
fetchSgvEntriesMock.mockRejectedValue(
new NightscoutApiError("Nightscout responded 401", 401),
);
await expect(syncUserNightscout("u1")).rejects.toThrow();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
const arg = recordFailureMock.mock.calls[0]![0];
expect(arg.integration).toBe("nightscout");
expect(recordSuccessMock).not.toHaveBeenCalled();
});
it("classifies a 401/403 as reauth_required", async () => {
fetchSgvEntriesMock.mockRejectedValue(
new NightscoutApiError("Nightscout responded 403", 403),
);
await expect(syncUserNightscout("u1")).rejects.toThrow();
expect(recordFailureMock.mock.calls[0]![0].kind).toBe("reauth_required");
});
it("classifies a network error as transient", async () => {
fetchSgvEntriesMock.mockRejectedValue(new Error("ECONNREFUSED"));
await expect(syncUserNightscout("u1")).rejects.toThrow();
expect(recordFailureMock.mock.calls[0]![0].kind).toBe("transient");
});
it("recomputes rollup buckets for the imported readings", async () => {
fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]);
await syncUserNightscout("u1");
expect(recomputeMock).toHaveBeenCalled();
expect(invalidateMock).toHaveBeenCalled();
});
});
+231
View File
@@ -0,0 +1,231 @@
/**
* Nightscout CGM client (v1.17.0).
*
* Nightscout is a per-user SELF-HOSTED continuous-glucose hub: the self-hoster
* runs their own instance (Railway / Heroku / Fly / a LAN box) and HealthLog
* is the CLIENT pulling SGV (sensor glucose value) entries off it. This module
* is the single source of truth for the wire shape, auth, the SGV parse, and
* the entry→Measurement mapping. No SDK — hand-rolled fetch over `safeFetch`,
* mirroring the Withings / WHOOP / Fitbit clients.
*
* SSRF (critical): every outbound call routes through `safeFetch` with
* `requirePublicHost: true` BY DEFAULT — public instances are the norm, and a
* malicious URL pointing at `169.254.169.254` / an RFC1918 admin panel must be
* refused. Only when the user has flipped `nightscoutAllowPrivateHost` (the
* explicit self-hoster opt-in for a LAN instance) does the public-host pin
* come off. Redirects are pinned to `manual` and a timeout is always composed
* so a tar-pit instance can't hold a worker.
*
* Auth: Nightscout accepts the API secret two ways — the `api-secret` HTTP
* header carrying the SHA1 hex of the secret, OR a `token` query param (a
* role-scoped access token). The stored field can hold either; default to the
* token query param (works for both role tokens and a raw secret on modern
* instances) and offer the SHA1 header mode for classic `API_SECRET` setups.
*/
import { createHash } from "node:crypto";
import type { MeasurementType } from "@/generated/prisma/client";
import { safeFetch } from "@/lib/safe-fetch";
/** Default per-request timeout for a Nightscout fetch. */
const NIGHTSCOUT_TIMEOUT_MS = 15_000;
/** Canonical glucose storage unit. Nightscout SGV is always mg/dL on the wire. */
export const NIGHTSCOUT_GLUCOSE_UNIT = "mg/dL" as const;
/** How auth is presented to the instance. */
export type NightscoutAuthMode = "token" | "header";
/**
* One raw SGV entry as Nightscout's `/api/v1/entries.json` returns it. Only
* the fields we read are typed; the upstream payload carries many more
* (`direction`, `noise`, `device`, …) that we deliberately ignore.
*/
export interface NightscoutSgvEntry {
_id?: string;
type?: string;
/** Sensor glucose value in mg/dL. */
sgv?: number | null;
/** Epoch milliseconds of the reading. */
date?: number | null;
dateString?: string;
}
/** A parsed, validated SGV entry (numeric value + millisecond timestamp). */
export interface ParsedSgvEntry {
id: string | null;
sgv: number;
date: number;
}
/** The mapped, source-tagged measurement a sync writes for one SGV entry. */
export interface NightscoutMeasurement {
type: MeasurementType;
value: number;
unit: typeof NIGHTSCOUT_GLUCOSE_UNIT;
measuredAt: Date;
externalId: string;
}
export interface FetchSgvOptions {
baseUrl: string;
token: string;
count: number;
allowPrivateHost: boolean;
/** Defaults to `token` (query param). `header` uses the api-secret SHA1. */
authMode?: NightscoutAuthMode;
/** Override the default timeout (tests). */
timeoutMs?: number;
}
/** SHA1 hex of a string — the `api-secret` header format Nightscout expects. */
export function sha1Hex(value: string): string {
return createHash("sha1").update(value, "utf8").digest("hex");
}
/**
* Strip a trailing slash so URL concatenation never produces `com//api`.
*/
function normaliseBase(baseUrl: string): string {
return baseUrl.replace(/\/+$/, "");
}
/**
* Build the `/api/v1/entries.json` URL for `count` SGV entries. Pins
* `type=sgv` upstream so non-glucose rows (meter BG, calibration) never reach
* the client; the parse re-checks anyway. When `token` is supplied it is
* appended as the `token` query param (the modern Nightscout auth path).
*/
export function buildEntriesUrl(
baseUrl: string,
count: number,
token?: string,
): string {
const url = new URL(`${normaliseBase(baseUrl)}/api/v1/entries.json`);
url.searchParams.set("count", String(count));
url.searchParams.set("type", "sgv");
if (token) url.searchParams.set("token", token);
return url.toString();
}
/**
* Keep only well-formed SGV rows: `type === "sgv"` (or untyped legacy rows
* carrying an `sgv`), a finite numeric value, and a millisecond `date`. Drops
* meter-BG (`mbg`) and calibration (`cal`) rows, and any row missing a value
* or timestamp. Returns `[]` for a non-array payload (a Nightscout error
* object, an HTML login page, …) so a misconfigured instance can't crash the
* sync.
*/
export function parseSgvEntries(payload: unknown): ParsedSgvEntry[] {
if (!Array.isArray(payload)) return [];
const out: ParsedSgvEntry[] = [];
for (const raw of payload as NightscoutSgvEntry[]) {
if (!raw || typeof raw !== "object") continue;
if (raw.type && raw.type !== "sgv") continue;
const sgv = raw.sgv;
const date = raw.date;
if (typeof sgv !== "number" || !Number.isFinite(sgv)) continue;
if (typeof date !== "number" || !Number.isFinite(date)) continue;
out.push({
id: typeof raw._id === "string" ? raw._id : null,
sgv,
date,
});
}
return out;
}
/**
* Map one SGV entry to a `BLOOD_GLUCOSE` mg/dL measurement. The `externalId`
* is `ns:<id>` when the entry carries Nightscout's `_id`, falling back to
* `ns:date:<epochMs>` — both stable per reading, so a re-sync collapses onto
* the existing row via the `(userId, type, source, externalId)` unique. mg/dL
* is stored canonical (Nightscout always reports mg/dL; the display unit is a
* user preference resolved elsewhere).
*/
export function mapSgvEntryToMeasurement(
entry: NightscoutSgvEntry,
): NightscoutMeasurement {
const externalId = entry._id
? `ns:${entry._id}`
: `ns:date:${entry.date ?? 0}`;
return {
type: "BLOOD_GLUCOSE",
value: entry.sgv as number,
unit: NIGHTSCOUT_GLUCOSE_UNIT,
measuredAt: new Date(entry.date as number),
externalId,
};
}
/** Externalid for a parsed entry — the canonical idempotency key. */
export function externalIdFor(entry: ParsedSgvEntry): string {
return entry.id ? `ns:${entry.id}` : `ns:date:${entry.date}`;
}
/**
* Raised when an instance answers non-2xx or is unreachable. The connect
* route + sync classifier switch on `status` to tell "wrong token" (401/403)
* from "instance down" (network) and surface a clear message to the user.
*/
export class NightscoutApiError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null) {
super(message);
this.name = "NightscoutApiError";
this.status = status;
}
}
/**
* Fetch the most recent `count` SGV entries from the user's instance.
*
* SSRF floor: `requirePublicHost` is `true` unless the user opted a private
* host in via `allowPrivateHost`. Redirects pinned to manual + a timeout
* always composed. Throws `NightscoutApiError` on a non-2xx response; re-raises
* a `SafeFetchError` (private host / timeout / network) unchanged so the caller
* can classify it.
*/
export async function fetchSgvEntries(
opts: FetchSgvOptions,
): Promise<ParsedSgvEntry[]> {
const authMode: NightscoutAuthMode = opts.authMode ?? "token";
const headers: Record<string, string> = { Accept: "application/json" };
let url: string;
if (authMode === "header" && opts.token) {
// Classic `API_SECRET` instances: the SHA1 hex in the api-secret header.
headers["api-secret"] = sha1Hex(opts.token);
url = buildEntriesUrl(opts.baseUrl, opts.count);
} else {
// Token query-param path (role-scoped access token; the default).
url = buildEntriesUrl(opts.baseUrl, opts.count, opts.token || undefined);
}
const res = await safeFetch(
url,
{ method: "GET", headers },
{
// Default-on public-host pin; relaxed ONLY when the user opted a
// private/LAN instance in. `followRedirects` defaults false (manual).
requirePublicHost: !opts.allowPrivateHost,
timeoutMs: opts.timeoutMs ?? NIGHTSCOUT_TIMEOUT_MS,
},
);
if (res.status < 200 || res.status >= 300) {
throw new NightscoutApiError(
`Nightscout responded ${res.status}`,
res.status,
);
}
let body: unknown;
try {
body = await res.json();
} catch {
throw new NightscoutApiError("Nightscout returned a non-JSON body", null);
}
return parseSgvEntries(body);
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Per-user Nightscout connection credentials (v1.17.0).
*
* Unlike WHOOP / Fitbit (OAuth, with a dedicated `*Connection` row), Nightscout
* is a URL + token the user pastes once. Both are stored encrypted on `User`
* (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`) via the fail-closed
* `*Encrypted` convention; the private-host opt-in is the plaintext boolean
* `nightscoutAllowPrivateHost`. This helper is the single read+decrypt path the
* sync + status + connect surfaces share.
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
export interface NightscoutCredentials {
baseUrl: string;
token: string;
allowPrivateHost: boolean;
}
/**
* Fetch and decrypt the user's Nightscout connection. Returns null when the
* URL is not configured (a token-less, fully-public instance still stores a
* URL, so the URL is the configured-marker, not the token).
*/
export async function getUserNightscoutCredentials(
userId: string,
): Promise<NightscoutCredentials | null> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
nightscoutUrlEncrypted: true,
nightscoutTokenEncrypted: true,
nightscoutAllowPrivateHost: true,
},
});
if (!user?.nightscoutUrlEncrypted) return null;
return {
baseUrl: decrypt(user.nightscoutUrlEncrypted),
token: user.nightscoutTokenEncrypted
? decrypt(user.nightscoutTokenEncrypted)
: "",
allowPrivateHost: user.nightscoutAllowPrivateHost,
};
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Nightscout CGM sync (v1.17.0).
*
* Pulls the most recent SGV entries from the user's self-hosted Nightscout
* instance and writes each as a `BLOOD_GLUCOSE` mg/dL Measurement tagged
* `source = NIGHTSCOUT`. This is the CGM density that makes the clinical
* glucose panel (TIR / GMI) real — a continuous stream rather than the manual
* spot readings.
*
* Idempotency: each row's `externalId` is derived from Nightscout's `_id` (or
* the reading's epoch ms), and the write is FIRST-WRITE-WINS — a CGM sample is
* an immutable canonical reading, so the `(userId, type, source, externalId)`
* unique collapses a re-sync onto the existing row and the `update` branch is a
* no-op (no value overwrite). This mirrors the Apple Health immutable-sample
* contract, not the WHOOP re-score overwrite.
*
* Status bookkeeping rides the shared integration ledger (`nightscout` key): a
* clean pass stamps `recordSyncSuccess`; an unreachable instance / wrong token
* records a classified `recordSyncFailure` and rethrows so the cohort runner
* can warn-and-continue.
*/
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import {
recordSyncFailure,
recordSyncSuccess,
type FailureKind,
} from "@/lib/integrations/status";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
externalIdFor,
fetchSgvEntries,
NightscoutApiError,
NIGHTSCOUT_GLUCOSE_UNIT,
type ParsedSgvEntry,
} from "./client";
import { getUserNightscoutCredentials } from "./credentials";
/**
* How many recent SGV entries to pull per sync tick. A CGM emits one reading
* every ~5 min (≈288/day); 576 covers ~48 h of catch-up so a missed hourly
* tick (or a brief outage) still backfills on the next run without paging the
* full instance. The far-history backfill walks larger windows separately.
*/
export const NIGHTSCOUT_SYNC_COUNT = 576;
/** Map a Nightscout HTTP status / network error onto the shared ledger kind. */
export function classifyNightscoutFailure(err: unknown): FailureKind {
if (err instanceof NightscoutApiError && err.status != null) {
// Wrong / missing token, or a token whose role can't read SGV.
if (err.status === 401 || err.status === 403) return "reauth_required";
// 4xx other than auth = a contract / config problem worth surfacing.
if (err.status >= 400 && err.status < 500) return "persistent";
}
// 5xx, timeout, DNS, connection refused — retry on the next tick.
return "transient";
}
/**
* Sync one user's recent SGV entries. Returns the count of rows written
* (newly inserted or re-confirmed). A user with no configured instance is a
* clean no-op (returns 0, touches no status row).
*/
export async function syncUserNightscout(
userId: string,
opts: { count?: number } = {},
): Promise<number> {
const creds = await getUserNightscoutCredentials(userId);
if (!creds) return 0;
let entries: ParsedSgvEntry[];
try {
entries = await fetchSgvEntries({
baseUrl: creds.baseUrl,
token: creds.token,
count: opts.count ?? NIGHTSCOUT_SYNC_COUNT,
allowPrivateHost: creds.allowPrivateHost,
});
} catch (err) {
await recordSyncFailure({
userId,
integration: "nightscout",
kind: classifyNightscoutFailure(err),
message: err instanceof Error ? err.message : String(err),
errorCode:
err instanceof NightscoutApiError && err.status != null
? String(err.status)
: undefined,
});
throw err;
}
const imported = await upsertNightscoutEntries(userId, entries);
await recordSyncSuccess(userId, "nightscout");
return imported;
}
/**
* Insert each SGV entry as an immutable BLOOD_GLUCOSE mg/dL row, then fold the
* rollup tier + invalidate status-insight caches once at the end (mirrors the
* Withings / WHOOP sync tail). First-write-wins: the `update` branch is empty
* so a re-sync of the same reading is a no-op rather than a rewrite. Best-effort
* on the per-row write + the rollup fold — one bad row never aborts the pass.
*/
export async function upsertNightscoutEntries(
userId: string,
entries: ParsedSgvEntry[],
): Promise<number> {
if (entries.length === 0) return 0;
const type = "BLOOD_GLUCOSE" as MeasurementType;
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const entry of entries) {
const externalId = externalIdFor(entry);
const measuredAt = new Date(entry.date);
try {
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type,
source: "NIGHTSCOUT",
externalId,
},
},
create: {
userId,
type,
source: "NIGHTSCOUT",
value: entry.sgv,
unit: NIGHTSCOUT_GLUCOSE_UNIT,
measuredAt,
externalId,
},
// First-write-wins: a CGM sample is immutable, so a re-sync of the
// same reading must not rewrite the stored value or measuredAt.
update: {},
});
touched.push({ type, measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(
`nightscout: failed to upsert measurement: ${err}`,
);
}
}
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`nightscout: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`nightscout: rollup fold failed for ${userId}: ${err}`,
);
}
return imported;
}
+6
View File
@@ -28,6 +28,12 @@ export const integrationKeys = {
*/
fitbitStatus: () => ["fitbit", "status"] as const,
// v1.17.0 — Nightscout CGM integration card. Self-contained status read
// (not the consolidated /api/integrations/status envelope), so the
// connect/disconnect mutations invalidate this single key.
nightscout: () => ["nightscout"] as const,
nightscoutStatus: () => ["nightscout", "status"] as const,
moodlogStatus: () => ["moodlog-status"] as const,
integrationsStatus: () => ["integrations", "status"] as const,
+41
View File
@@ -0,0 +1,41 @@
import { z } from "zod/v4";
/**
* Per-user Nightscout connection input (v1.17.0). The self-hoster points
* HealthLog at their own Nightscout instance (Railway / Heroku / Fly / a LAN
* box) and pastes the instance's API token. Both fields are stored encrypted
* on `User` (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`); the
* private-host opt-in maps to `nightscoutAllowPrivateHost`.
*
* The URL is validated to be a parseable http(s) origin at input time. The
* SSRF floor (public-host requirement, unless `allowPrivateHost` is set) is
* enforced at fetch time by the client through `safeFetch` — input-shape
* validation here only rejects garbage early so the connect route can return
* a clear 422 instead of letting a malformed string reach the egress layer.
*/
export const nightscoutConnectSchema = z.object({
// Trimmed: a trailing space / newline from a copy button reaches the
// instance verbatim and produces an opaque DNS failure.
url: z
.string()
.trim()
.min(1)
.max(2048)
.refine((value) => {
try {
const u = new URL(value);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
}, "Must be a valid http(s) URL"),
// Nightscout API tokens are opaque strings (a role-scoped access token or
// the raw `API_SECRET`). Optional: a fully public instance with
// `AUTH_DEFAULT_ROLES=readable` serves SGV entries without a token.
token: z.string().trim().max(512).optional().default(""),
// The explicit self-hoster opt-in to allow a private / LAN Nightscout host
// past the public-host SSRF floor. Defaults false (public instances).
allowPrivateHost: z.boolean().optional().default(false),
});
export type NightscoutConnectInput = z.infer<typeof nightscoutConnectSchema>;