mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
4884f019b7
commit
f5d0b2f696
23 files changed
+1803
-9
No files matched your search
+20
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user