Merge pull request #631 from MBombeck/release/v1.32.28

release/v1.32.28 — Oura catches up after a gap, and the last four providers get manual sync
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-25 05:03:46 +02:00
commit a11717e65a
33 files changed
+1390 -30

No files matched your search

+24
View File
@@ -2,6 +2,30 @@
## [Unreleased]
## [1.32.28] — 2026-07-25
Oura used to look back exactly one week and no further. If the sync had been
broken for longer than that, or if the ring sat unused and the older nights
only reached Oura's cloud later, those days fell outside the window and were
never imported. Nothing said so, because a sync that finds nothing still counts
as a sync that worked.
- **Oura catches up after a gap.** The window is now measured from the newest
Oura reading actually on file rather than from a fixed week. A ten day gap
pulls eleven days on the next run and closes itself; a month is the ceiling.
When data is arriving normally the window stays at a week and nothing about
the hourly run changes.
- **A fresh Oura connection imports a month** instead of a week. Deeper history
is a separate question and is not part of this release.
- **Sync now for Oura, Polar, Strava and Nightscout.** These four had no way to
ask for a sync; they only ever ran on their scheduled pass. Each connected
card gets the same button WHOOP and Fitbit already had, limited to a few runs
per minute.
- **Connecting starts the first sync straight away.** Oura, Polar and
Nightscout used to sit empty for up to an hour after connecting, waiting for
the next scheduled pass. The first pull now runs on connect, and the schedule
stays as the fallback.
## [1.32.27] — 2026-07-25
Target and threshold panels now follow the metric and imperial preference. The
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.27
version: 1.32.28
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+12
View File
@@ -5543,6 +5543,9 @@
"nightscoutUpdate": "Änderungen speichern",
"nightscoutConnected": "Nightscout verbunden.",
"nightscoutConnectFailed": "Verbindung zu Nightscout fehlgeschlagen.",
"nightscoutSync": "Jetzt synchronisieren",
"nightscoutSyncResult": "{count} Messwerte synchronisiert",
"nightscoutSyncFailed": "Synchronisierung fehlgeschlagen",
"nightscoutDisconnect": "Trennen",
"nightscoutDisconnectTitle": "Nightscout trennen?",
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
@@ -5558,6 +5561,9 @@
"polarSaveCredentials": "Speichern",
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"polarConnect": "Polar verbinden",
"polarSync": "Jetzt synchronisieren",
"polarSyncResult": "{count} Messwerte synchronisiert",
"polarSyncFailed": "Synchronisierung fehlgeschlagen",
"polarDisconnect": "Trennen",
"polarDisconnectTitle": "Polar trennen?",
"polarDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.",
@@ -5585,6 +5591,9 @@
"ouraSaveCredentials": "Speichern",
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"ouraConnect": "Oura verbinden",
"ouraSync": "Jetzt synchronisieren",
"ouraSyncResult": "{count} Messwerte synchronisiert",
"ouraSyncFailed": "Synchronisierung fehlgeschlagen",
"ouraDisconnect": "Trennen",
"ouraDisconnectTitle": "Oura trennen?",
"ouraDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.",
@@ -5813,6 +5822,9 @@
"stravaSaveCredentials": "Speichern",
"stravaUnavailable": "Strava ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Strava-API-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"stravaConnect": "Strava verbinden",
"stravaSync": "Jetzt synchronisieren",
"stravaSyncResult": "{count} Messwerte synchronisiert",
"stravaSyncFailed": "Synchronisierung fehlgeschlagen",
"stravaDisconnect": "Trennen",
"stravaDisconnectTitle": "Strava trennen?",
"stravaDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Aktivitäten bleiben in deinem Verlauf.",
+12
View File
@@ -5533,6 +5533,9 @@
"nightscoutUpdate": "Save changes",
"nightscoutConnected": "Nightscout connected.",
"nightscoutConnectFailed": "Could not connect to Nightscout.",
"nightscoutSync": "Sync now",
"nightscoutSyncResult": "{count} measurements synchronized",
"nightscoutSyncFailed": "Sync failed",
"nightscoutDisconnect": "Disconnect",
"nightscoutDisconnectTitle": "Disconnect Nightscout?",
"nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.",
@@ -5548,6 +5551,9 @@
"polarSaveCredentials": "Save",
"polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.",
"polarConnect": "Connect Polar",
"polarSync": "Sync now",
"polarSyncResult": "{count} measurements synchronized",
"polarSyncFailed": "Sync failed",
"polarDisconnect": "Disconnect",
"polarDisconnectTitle": "Disconnect Polar?",
"polarDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.",
@@ -5575,6 +5581,9 @@
"ouraSaveCredentials": "Save",
"ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.",
"ouraConnect": "Connect Oura",
"ouraSync": "Sync now",
"ouraSyncResult": "{count} measurements synchronized",
"ouraSyncFailed": "Sync failed",
"ouraDisconnect": "Disconnect",
"ouraDisconnectTitle": "Disconnect Oura?",
"ouraDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.",
@@ -5803,6 +5812,9 @@
"stravaSaveCredentials": "Save",
"stravaUnavailable": "Strava is not configured on this server. Add your own Strava API credentials above, or ask your administrator to configure the shared app.",
"stravaConnect": "Connect Strava",
"stravaSync": "Sync now",
"stravaSyncResult": "{count} measurements synchronized",
"stravaSyncFailed": "Sync failed",
"stravaDisconnect": "Disconnect",
"stravaDisconnectTitle": "Disconnect Strava?",
"stravaDisconnectDescription": "Future syncs stop. Activities already imported stay in your history.",
+12
View File
@@ -5545,6 +5545,9 @@
"nightscoutUpdate": "Guardar cambios",
"nightscoutConnected": "Nightscout conectado.",
"nightscoutConnectFailed": "No se pudo conectar con Nightscout.",
"nightscoutSync": "Sincronizar ahora",
"nightscoutSyncResult": "{count} mediciones sincronizadas",
"nightscoutSyncFailed": "Error de sincronización",
"nightscoutDisconnect": "Desconectar",
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
@@ -5560,6 +5563,9 @@
"polarSaveCredentials": "Guardar",
"polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.",
"polarConnect": "Conectar Polar",
"polarSync": "Sincronizar ahora",
"polarSyncResult": "{count} mediciones sincronizadas",
"polarSyncFailed": "Error de sincronización",
"polarDisconnect": "Desconectar",
"polarDisconnectTitle": "¿Desconectar Polar?",
"polarDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.",
@@ -5587,6 +5593,9 @@
"ouraSaveCredentials": "Guardar",
"ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.",
"ouraConnect": "Conectar Oura",
"ouraSync": "Sincronizar ahora",
"ouraSyncResult": "{count} mediciones sincronizadas",
"ouraSyncFailed": "Error de sincronización",
"ouraDisconnect": "Desconectar",
"ouraDisconnectTitle": "¿Desconectar Oura?",
"ouraDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.",
@@ -5815,6 +5824,9 @@
"stravaSaveCredentials": "Guardar",
"stravaUnavailable": "Strava no está configurado en este servidor. Añade arriba tus propias credenciales de API de Strava o pide a tu administrador que configure la app compartida.",
"stravaConnect": "Conectar Strava",
"stravaSync": "Sincronizar ahora",
"stravaSyncResult": "{count} mediciones sincronizadas",
"stravaSyncFailed": "Error de sincronización",
"stravaDisconnect": "Desconectar",
"stravaDisconnectTitle": "¿Desconectar Strava?",
"stravaDisconnectDescription": "Las sincronizaciones futuras se detienen. Las actividades ya importadas permanecen en tu historial.",
+12
View File
@@ -5547,6 +5547,9 @@
"nightscoutUpdate": "Enregistrer les modifications",
"nightscoutConnected": "Nightscout connecté.",
"nightscoutConnectFailed": "Impossible de se connecter à Nightscout.",
"nightscoutSync": "Synchroniser",
"nightscoutSyncResult": "{count} mesures synchronisées",
"nightscoutSyncFailed": "Échec de la synchronisation",
"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.",
@@ -5562,6 +5565,9 @@
"polarSaveCredentials": "Enregistrer",
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"polarConnect": "Connecter Polar",
"polarSync": "Synchroniser",
"polarSyncResult": "{count} mesures synchronisées",
"polarSyncFailed": "Échec de la synchronisation",
"polarDisconnect": "Déconnecter",
"polarDisconnectTitle": "Déconnecter Polar ?",
"polarDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.",
@@ -5589,6 +5595,9 @@
"ouraSaveCredentials": "Enregistrer",
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"ouraConnect": "Connecter Oura",
"ouraSync": "Synchroniser",
"ouraSyncResult": "{count} mesures synchronisées",
"ouraSyncFailed": "Échec de la synchronisation",
"ouraDisconnect": "Déconnecter",
"ouraDisconnectTitle": "Déconnecter Oura ?",
"ouraDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.",
@@ -5817,6 +5826,9 @@
"stravaSaveCredentials": "Enregistrer",
"stravaUnavailable": "Strava n'est pas configuré sur ce serveur. Ajoute tes propres identifiants API Strava ci-dessus ou demande à ton administrateur de configurer l'application partagée.",
"stravaConnect": "Connecter Strava",
"stravaSync": "Synchroniser",
"stravaSyncResult": "{count} mesures synchronisées",
"stravaSyncFailed": "Échec de la synchronisation",
"stravaDisconnect": "Déconnecter",
"stravaDisconnectTitle": "Déconnecter Strava ?",
"stravaDisconnectDescription": "Les synchronisations futures s'arrêtent. Les activités déjà importées restent dans ton historique.",
+12
View File
@@ -5537,6 +5537,9 @@
"nightscoutUpdate": "Salva modifiche",
"nightscoutConnected": "Nightscout connesso.",
"nightscoutConnectFailed": "Impossibile connettersi a Nightscout.",
"nightscoutSync": "Sincronizza ora",
"nightscoutSyncResult": "{count} misurazioni sincronizzate",
"nightscoutSyncFailed": "Sincronizzazione non riuscita",
"nightscoutDisconnect": "Disconnetti",
"nightscoutDisconnectTitle": "Disconnettere Nightscout?",
"nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.",
@@ -5552,6 +5555,9 @@
"polarSaveCredentials": "Salva",
"polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"polarConnect": "Collega Polar",
"polarSync": "Sincronizza ora",
"polarSyncResult": "{count} misurazioni sincronizzate",
"polarSyncFailed": "Sincronizzazione non riuscita",
"polarDisconnect": "Disconnetti",
"polarDisconnectTitle": "Disconnettere Polar?",
"polarDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.",
@@ -5579,6 +5585,9 @@
"ouraSaveCredentials": "Salva",
"ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"ouraConnect": "Collega Oura",
"ouraSync": "Sincronizza ora",
"ouraSyncResult": "{count} misurazioni sincronizzate",
"ouraSyncFailed": "Sincronizzazione non riuscita",
"ouraDisconnect": "Disconnetti",
"ouraDisconnectTitle": "Disconnettere Oura?",
"ouraDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.",
@@ -5807,6 +5816,9 @@
"stravaSaveCredentials": "Salva",
"stravaUnavailable": "Strava non è configurato su questo server. Aggiungi sopra le tue credenziali API di Strava o chiedi al tuo amministratore di configurare l'app condivisa.",
"stravaConnect": "Collega Strava",
"stravaSync": "Sincronizza ora",
"stravaSyncResult": "{count} misurazioni sincronizzate",
"stravaSyncFailed": "Sincronizzazione non riuscita",
"stravaDisconnect": "Disconnetti",
"stravaDisconnectTitle": "Disconnettere Strava?",
"stravaDisconnectDescription": "Le sincronizzazioni future si interrompono. Le attività già importate restano nella cronologia.",
+12
View File
@@ -5531,6 +5531,9 @@
"nightscoutUpdate": "Zapisz zmiany",
"nightscoutConnected": "Nightscout połączony.",
"nightscoutConnectFailed": "Nie można połączyć z Nightscout.",
"nightscoutSync": "Synchronizuj teraz",
"nightscoutSyncResult": "Zsynchronizowano {count} pomiarów",
"nightscoutSyncFailed": "Synchronizacja nie powiodła się",
"nightscoutDisconnect": "Rozłącz",
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
@@ -5546,6 +5549,9 @@
"polarSaveCredentials": "Zapisz",
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"polarConnect": "Połącz Polar",
"polarSync": "Synchronizuj teraz",
"polarSyncResult": "Zsynchronizowano {count} pomiarów",
"polarSyncFailed": "Synchronizacja nie powiodła się",
"polarDisconnect": "Rozłącz",
"polarDisconnectTitle": "Rozłączyć Polar?",
"polarDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.",
@@ -5573,6 +5579,9 @@
"ouraSaveCredentials": "Zapisz",
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"ouraConnect": "Połącz Oura",
"ouraSync": "Synchronizuj teraz",
"ouraSyncResult": "Zsynchronizowano {count} pomiarów",
"ouraSyncFailed": "Synchronizacja nie powiodła się",
"ouraDisconnect": "Rozłącz",
"ouraDisconnectTitle": "Rozłączyć Oura?",
"ouraDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.",
@@ -5801,6 +5810,9 @@
"stravaSaveCredentials": "Zapisz",
"stravaUnavailable": "Strava nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane API Strava lub poproś administratora o skonfigurowanie wspólnej aplikacji.",
"stravaConnect": "Połącz Strava",
"stravaSync": "Synchronizuj teraz",
"stravaSyncResult": "Zsynchronizowano {count} pomiarów",
"stravaSyncFailed": "Synchronizacja nie powiodła się",
"stravaDisconnect": "Rozłącz",
"stravaDisconnectTitle": "Rozłączyć Strava?",
"stravaDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane aktywności pozostaną w historii.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.27",
"version": "1.32.28",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.27";
/* @sw-version-fallback */ "v1.32.28";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -4,10 +4,14 @@ 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(),
}));
const { fetchSgvEntriesMock, rateLimitMock, bossSend, bossMock } = vi.hoisted(
() => ({
fetchSgvEntriesMock: vi.fn(),
rateLimitMock: vi.fn(),
bossSend: vi.fn(),
bossMock: vi.fn(),
}),
);
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
@@ -18,10 +22,14 @@ vi.mock("@/lib/db", () => ({
prisma: { user: { update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ addWarning: 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/jobs/boss-instance", () => ({ getGlobalBoss: bossMock }));
vi.mock("@/lib/nightscout/client", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/nightscout/client")>();
@@ -66,6 +74,8 @@ beforeEach(() => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 });
fetchSgvEntriesMock.mockResolvedValue([{ id: "x", sgv: 100, date: 1 }]);
bossSend.mockResolvedValue("job-1");
bossMock.mockReturnValue({ send: bossSend });
});
describe("POST /api/nightscout/connect", () => {
@@ -208,3 +218,47 @@ describe("POST /api/nightscout/connect", () => {
expect(fetchSgvEntriesMock).not.toHaveBeenCalled();
});
});
/**
* v1.32.28 — a Nightscout connection pulls its first glucose window straight
* away instead of leaving the card empty until the next hourly tick.
*/
describe("POST /api/nightscout/connect — connect-time first sync", () => {
it("enqueues exactly one targeted nightscout-sync job for the connecting user", async () => {
const res = await post(
req({ url: "https://ns.example.com", token: "tok" }),
);
expect(res.status).toBe(200);
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend).toHaveBeenCalledWith("nightscout-sync", { userId: "u1" });
});
it("does not enqueue when the connection was rejected", async () => {
const res = await post(req({ url: "not a url" }));
expect(res.status).toBe(422);
expect(bossSend).not.toHaveBeenCalled();
});
it("still connects when no boss instance is available", async () => {
bossMock.mockReturnValue(null);
const res = await post(
req({ url: "https://ns.example.com", token: "tok" }),
);
expect(res.status).toBe(200);
expect(bossSend).not.toHaveBeenCalled();
});
it("still connects when the enqueue itself rejects", async () => {
bossSend.mockRejectedValue(new Error("queue unreachable"));
const res = await post(
req({ url: "https://ns.example.com", token: "tok" }),
);
expect(res.status).toBe(200);
});
});
+18 -1
View File
@@ -4,11 +4,13 @@ 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 { annotate, getEvent } 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 { NIGHTSCOUT_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { SafeFetchError } from "@/lib/safe-fetch";
import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client";
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
@@ -116,5 +118,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
await markReconnected(user.id, "nightscout");
await auditLog("nightscout.connect", { userId: user.id });
// Pull the first glucose window now instead of leaving the user staring at an
// empty card until the next hourly tick. Best-effort by design: the hourly
// cron remains the safety net, so a missing boss instance must never fail the
// connect response the user just earned.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(NIGHTSCOUT_SYNC_QUEUE, { userId: user.id })
.catch((err) =>
getEvent()?.addWarning(
`nightscout-sync connect enqueue failed: ${err}`,
),
);
}
return apiSuccess({ connected: true });
});
@@ -0,0 +1,116 @@
/**
* v1.32.28 — manual Nightscout sync.
*
* The error arm carries the load here: a Nightscout base URL carries the API
* token as a query parameter, so an upstream message can contain the user's
* secret. The route answers with a fixed string and never the caught message.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/nightscout/sync", () => ({ syncUserNightscout: vi.fn() }));
import { POST } from "../route";
import { HttpError, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { syncUserNightscout } from "@/lib/nightscout/sync";
function request(): NextRequest {
return new NextRequest("http://localhost/api/nightscout/sync", {
method: "POST",
});
}
// The route takes no arguments (no body, no flags); the alias keeps the
// direct invoke typed while still handing apiHandler a real request.
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
async function envelope(response: Response) {
return (await response.json()) as {
data: { imported?: number } | null;
error: string | null;
meta?: { errorCode?: string };
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
});
describe("POST /api/nightscout/sync", () => {
it("syncs the session user and answers with the freshly inserted count", async () => {
vi.mocked(syncUserNightscout).mockResolvedValue(36);
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 36 },
error: null,
});
expect(syncUserNightscout).toHaveBeenCalledWith("u1");
});
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const response = await post(request());
expect(response.status).toBe(401);
expect(syncUserNightscout).not.toHaveBeenCalled();
});
it("refuses past the per-user rate limit without touching the sync", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(429);
expect(body.meta?.errorCode).toBe("rate_limited_self");
expect(syncUserNightscout).not.toHaveBeenCalled();
expect(checkRateLimit).toHaveBeenCalledWith(
"nightscout-sync:u1",
5,
60_000,
);
});
it("never echoes the token-bearing instance URL into the response body", async () => {
vi.mocked(syncUserNightscout).mockRejectedValue(
new Error(
"GET https://cgm.example.org/api/v1/entries.json?token=hunter2secret failed with 401",
),
);
const response = await post(request());
const body = await envelope(response);
const serialised = JSON.stringify(body);
expect(response.status).toBe(502);
expect(body.error).toBe("Nightscout sync failed");
expect(serialised).not.toContain("hunter2secret");
expect(serialised).not.toContain("cgm.example.org");
expect(serialised).not.toContain("token=");
});
});
+42
View File
@@ -0,0 +1,42 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserNightscout } from "@/lib/nightscout/sync";
/**
* Manually trigger a Nightscout sync for the current user (v1.32.28).
*
* Pulls the recent SGV window from the configured instance; `imported` counts
* the readings that were genuinely new this run, not the ones re-confirmed.
*
* No body and no `fullSync` flag: there is no full-history arm to call.
*
* The error arm answers with a fixed message and NEVER the caught error's own.
* A Nightscout base URL carries the API token as a query parameter, so an
* upstream message can contain the user's secret; echoing it here would put it
* in a response body, a browser devtools panel, and any proxy log in between.
* The classified failure is already on the `nightscout` ledger, recorded by the
* provider with its redaction rules applied.
*
* Rate-limited 5/60s per user, matching the hardened Fitbit / Google Health
* routes; every run is an outbound call to a user-supplied host.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "nightscout.sync" } });
const rl = await checkRateLimit(`nightscout-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
try {
const imported = await syncUserNightscout(user.id);
return apiSuccess({ imported });
} catch {
return apiError("Nightscout sync failed", 502);
}
});
@@ -12,21 +12,34 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setError: vi.fn(), setAuth: vi.fn() }),
getEvent: () => ({
setError: vi.fn(),
setAuth: vi.fn(),
addWarning: 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() }));
const { exchangeMock, getCredsMock, matchMock, verifyMock } = vi.hoisted(
() => ({
exchangeMock: vi.fn(),
getCredsMock: vi.fn(),
matchMock: vi.fn(),
verifyMock: vi.fn(),
}),
);
const {
exchangeMock,
getCredsMock,
matchMock,
verifyMock,
bossSend,
bossMock,
} = vi.hoisted(() => ({
exchangeMock: vi.fn(),
getCredsMock: vi.fn(),
matchMock: vi.fn(),
verifyMock: vi.fn(),
bossSend: vi.fn(),
bossMock: vi.fn(),
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock }));
vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock }));
vi.mock("@/lib/oura/credentials", () => ({
@@ -75,6 +88,8 @@ beforeEach(() => {
exchangeMock.mockResolvedValue({ access_token: "at", refresh_token: "rt" });
userUpdate.mockResolvedValue({});
(getSession as ReturnType<typeof vi.fn>).mockResolvedValue(null);
bossSend.mockResolvedValue("job-1");
bossMock.mockReturnValue({ send: bossSend });
});
describe("GET /api/oura/callback", () => {
@@ -123,3 +138,36 @@ describe("GET /api/oura/callback", () => {
expect(res.headers.get("location")).toContain("oura=connected");
});
});
/**
* v1.32.28 — connecting starts a sync straight away. The poll handler has
* accepted a single-user payload since it was written; this callback is the
* producer that was missing, and without it a fresh connection showed an empty
* card for up to an hour.
*/
describe("GET /api/oura/callback — connect-time first sync", () => {
it("enqueues exactly one targeted oura-sync job for the connecting user", async () => {
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("oura=connected");
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend).toHaveBeenCalledWith("oura-sync", { userId: "u1" });
});
it("still connects when no boss instance is available", async () => {
bossMock.mockReturnValue(null);
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("oura=connected");
expect(bossSend).not.toHaveBeenCalled();
});
it("still connects when the enqueue itself rejects", async () => {
bossSend.mockRejectedValue(new Error("queue unreachable"));
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("oura=connected");
});
});
+17
View File
@@ -12,6 +12,8 @@ import {
verifySignedState,
} from "@/lib/oauth/signed-state";
import { markReconnected } from "@/lib/integrations/status";
import { OURA_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -82,6 +84,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
await auditLog("oura.connect", { userId });
await markReconnected(userId, "oura");
// Start the first sync now instead of leaving the user staring at an empty
// card until the next hourly tick. The poll handler has accepted a
// single-user payload since it was written; this is the producer that was
// missing. Reconnects ride the same path. Best-effort by design: the hourly
// cron remains the safety net, so a missing boss instance must never strand
// the redirect.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(OURA_SYNC_QUEUE, { userId })
.catch((err) =>
getEvent()?.addWarning(`oura-sync connect enqueue failed: ${err}`),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?oura=connected",
@@ -0,0 +1,104 @@
/**
* v1.32.28 — manual Oura sync. Until now Oura only ran on its hourly tick.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/oura/sync", () => ({ syncUserOura: vi.fn() }));
import { POST } from "../route";
import { HttpError, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { syncUserOura } from "@/lib/oura/sync";
function request(): NextRequest {
return new NextRequest("http://localhost/api/oura/sync", { method: "POST" });
}
// The route takes no arguments (no body, no flags); the alias keeps the
// direct invoke typed while still handing apiHandler a real request.
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
async function envelope(response: Response) {
return (await response.json()) as {
data: { imported?: number } | null;
error: string | null;
meta?: { errorCode?: string };
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({
user: { id: "u1" },
} as never);
});
describe("POST /api/oura/sync", () => {
it("syncs the session user and answers with the imported count", async () => {
vi.mocked(syncUserOura).mockResolvedValue(12);
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 12 },
error: null,
});
// The id comes from the session, never from the request.
expect(syncUserOura).toHaveBeenCalledWith("u1");
});
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const response = await post(request());
expect(response.status).toBe(401);
expect(syncUserOura).not.toHaveBeenCalled();
});
it("refuses past the per-user rate limit without touching the sync", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(429);
expect(body.meta?.errorCode).toBe("rate_limited_self");
expect(syncUserOura).not.toHaveBeenCalled();
expect(checkRateLimit).toHaveBeenCalledWith("oura-sync:u1", 5, 60_000);
});
it("answers 502 generically when the sync throws", async () => {
vi.mocked(syncUserOura).mockRejectedValue(
new Error("oura said: token abc123 is invalid"),
);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(502);
expect(body.error).toBe("Oura sync failed");
expect(JSON.stringify(body)).not.toContain("abc123");
});
});
+42
View File
@@ -0,0 +1,42 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserOura } from "@/lib/oura/sync";
/**
* Manually trigger an Oura sync for the current user (v1.32.28).
*
* Until now Oura only ever synced on its hourly cron tick, so a user who could
* see something was behind had no way to ask for a fresh pull.
*
* No body and no `fullSync` flag: `syncUserOura` has no full-history arm, and
* the window is not the caller's to choose — the sync derives it from the
* newest row it already holds, which is what makes a catch-up honest. Accepting
* a `lookbackDays` here would hand an unvalidated cost knob to the client.
*
* Rate-limited 5/60s per user, matching the hardened Fitbit / Google Health
* routes; a sync is a fan-out of upstream requests plus a batched write, so a
* tight retry loop must not be able to pin Prisma or burn the Oura budget.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.sync" } });
const rl = await checkRateLimit(`oura-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
try {
const imported = await syncUserOura(user.id);
return apiSuccess({ imported });
} catch {
// The failure is already on the `oura` ledger, classified by the provider
// that owns the error shape. Answer generically — an upstream message can
// carry detail that has no business in a response body.
return apiError("Oura sync failed", 502);
}
});
@@ -8,6 +8,8 @@ const {
registerUserMock,
getCredsMock,
markReconnectedMock,
bossSend,
bossMock,
} = vi.hoisted(() => ({
stateMatchesCookieMock: vi.fn(),
verifySignedStateMock: vi.fn(),
@@ -16,6 +18,8 @@ const {
registerUserMock: vi.fn(),
getCredsMock: vi.fn(),
markReconnectedMock: vi.fn(),
bossSend: vi.fn(),
bossMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
@@ -24,7 +28,11 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setAuth: vi.fn(), setError: vi.fn() }),
getEvent: () => ({
setAuth: vi.fn(),
setError: vi.fn(),
addWarning: vi.fn(),
}),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
@@ -44,6 +52,7 @@ vi.mock("@/lib/oauth/signed-state", () => ({
vi.mock("@/lib/integrations/status", () => ({
markReconnected: markReconnectedMock,
}));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock }));
import { GET } from "../route";
@@ -68,6 +77,8 @@ beforeEach(() => {
getSessionMock.mockResolvedValue(null);
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
exchangeCodeMock.mockResolvedValue({ access_token: "tok", x_user_id: 42 });
bossSend.mockResolvedValue("job-1");
bossMock.mockReturnValue({ send: bossSend });
registerUserMock.mockResolvedValue(undefined);
});
@@ -100,3 +111,35 @@ describe("GET /api/polar/callback", () => {
expect(registerUserMock).not.toHaveBeenCalled();
});
});
/**
* v1.32.28 — connecting starts a sync straight away instead of leaving the card
* empty until the next hourly tick. The poll handler has accepted a single-user
* payload since it was written; this callback is the producer that was missing.
*/
describe("GET /api/polar/callback — connect-time first sync", () => {
it("enqueues exactly one targeted polar-sync job for the connecting user", async () => {
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("polar=connected");
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend).toHaveBeenCalledWith("polar-sync", { userId: "u1" });
});
it("still connects when no boss instance is available", async () => {
bossMock.mockReturnValue(null);
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("polar=connected");
expect(bossSend).not.toHaveBeenCalled();
});
it("still connects when the enqueue itself rejects", async () => {
bossSend.mockRejectedValue(new Error("queue unreachable"));
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("polar=connected");
});
});
+15
View File
@@ -12,6 +12,8 @@ import {
verifySignedState,
} from "@/lib/oauth/signed-state";
import { markReconnected } from "@/lib/integrations/status";
import { POLAR_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -102,6 +104,19 @@ export const GET = apiHandler(async (request: NextRequest) => {
await auditLog("polar.connect", { userId });
await markReconnected(userId, "polar");
// Start the first sync now instead of leaving the user staring at an empty
// card until the next hourly tick. Best-effort by design: the hourly cron
// remains the safety net, so a missing boss instance must never strand the
// redirect.
const boss = getGlobalBoss();
if (boss) {
await boss
.send(POLAR_SYNC_QUEUE, { userId })
.catch((err) =>
getEvent()?.addWarning(`polar-sync connect enqueue failed: ${err}`),
);
}
const response = NextResponse.redirect(
new URL(
"/settings/integrations?polar=connected",
@@ -0,0 +1,132 @@
/**
* v1.32.28 — manual Polar sync.
*
* The entry point matters as much as the behaviour: the route must call
* `syncUserPolarLegs`, the same seam the hourly poll uses, so both legs run
* with the attribution rules built for them. Calling the vitals leg alone would
* quietly import half of Polar and stamp the ledger wrong, and a naive test
* that only asserted a row count would not notice.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { syncUserPolarMock, syncUserPolarWorkoutsMock } = vi.hoisted(() => ({
syncUserPolarMock: vi.fn(),
syncUserPolarWorkoutsMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
// Mock the two LEGS rather than the wrapper, so the real `syncUserPolarLegs`
// runs and the test can see whether both of them were driven.
vi.mock("@/lib/polar/sync", () => ({
syncUserPolar: syncUserPolarMock,
recordPolarSyncFailure: vi.fn(),
}));
vi.mock("@/lib/polar/sync-workouts", () => ({
syncUserPolarWorkouts: syncUserPolarWorkoutsMock,
}));
import { POST } from "../route";
import { HttpError, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
function request(): NextRequest {
return new NextRequest("http://localhost/api/polar/sync", { method: "POST" });
}
// The route takes no arguments (no body, no flags); the alias keeps the
// direct invoke typed while still handing apiHandler a real request.
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
async function envelope(response: Response) {
return (await response.json()) as {
data: { imported?: number } | null;
error: string | null;
meta?: { errorCode?: string };
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
syncUserPolarMock.mockResolvedValue(0);
syncUserPolarWorkoutsMock.mockResolvedValue(0);
});
describe("POST /api/polar/sync", () => {
it("runs both Polar legs and totals what they imported", async () => {
syncUserPolarMock.mockResolvedValue(5);
syncUserPolarWorkoutsMock.mockResolvedValue(2);
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 7 },
error: null,
});
expect(syncUserPolarMock).toHaveBeenCalledWith("u1");
expect(syncUserPolarWorkoutsMock).toHaveBeenCalledWith("u1");
});
it("goes through the shared legs entry point, not the provider internals", () => {
const source = readFileSync(join(__dirname, "..", "route.ts"), "utf8");
expect(source).toContain("syncUserPolarLegs");
expect(source).not.toMatch(/from "@\/lib\/polar\//);
});
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const response = await post(request());
expect(response.status).toBe(401);
expect(syncUserPolarMock).not.toHaveBeenCalled();
expect(syncUserPolarWorkoutsMock).not.toHaveBeenCalled();
});
it("refuses past the per-user rate limit without touching the sync", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(429);
expect(body.meta?.errorCode).toBe("rate_limited_self");
expect(syncUserPolarMock).not.toHaveBeenCalled();
expect(checkRateLimit).toHaveBeenCalledWith("polar-sync:u1", 5, 60_000);
});
it("answers 502 generically when a leg throws", async () => {
syncUserPolarMock.mockRejectedValue(
new Error("polar said: token abc123 is invalid"),
);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(502);
expect(body.error).toBe("Polar sync failed");
expect(JSON.stringify(body)).not.toContain("abc123");
});
});
+41
View File
@@ -0,0 +1,41 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserPolarLegs } from "@/lib/jobs/reminder/polar-sync";
/**
* Manually trigger a Polar sync for the current user (v1.32.28).
*
* Calls `syncUserPolarLegs` — the same entry point the hourly poll uses — so
* both Polar legs run with the attribution rules that were built for them
* (vitals then workouts, independent failure recording, one success stamp).
* Reaching past it into `@/lib/polar/*` would run half the provider and get the
* ledger wrong. The module graph behind this import bottoms out in the shared
* Prisma client and constants, so it is safe on the request path.
*
* No body and no `fullSync` flag: there is no full-history arm to call.
*
* Rate-limited 5/60s per user, matching the hardened Fitbit / Google Health
* routes.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.sync" } });
const rl = await checkRateLimit(`polar-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
try {
const imported = await syncUserPolarLegs(user.id);
return apiSuccess({ imported });
} catch {
// Both legs record their own classified failure on the `polar` ledger.
// Answer generically so no upstream detail rides the response body.
return apiError("Polar sync failed", 502);
}
});
@@ -0,0 +1,104 @@
/**
* v1.32.28 — manual Strava sync. Incremental by nature (Strava carries a real
* cursor); no full-history arm is exposed here, that stays with the backfill.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/strava/sync", () => ({ syncUserStrava: vi.fn() }));
import { POST } from "../route";
import { HttpError, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { syncUserStrava } from "@/lib/strava/sync";
function request(): NextRequest {
return new NextRequest("http://localhost/api/strava/sync", {
method: "POST",
});
}
// The route takes no arguments (no body, no flags); the alias keeps the
// direct invoke typed while still handing apiHandler a real request.
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
async function envelope(response: Response) {
return (await response.json()) as {
data: { imported?: number } | null;
error: string | null;
meta?: { errorCode?: string };
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
});
describe("POST /api/strava/sync", () => {
it("syncs the session user and answers with the imported count", async () => {
vi.mocked(syncUserStrava).mockResolvedValue(4);
const response = await post(request());
expect(response.status).toBe(200);
expect(await envelope(response)).toEqual({
data: { imported: 4 },
error: null,
});
expect(syncUserStrava).toHaveBeenCalledWith("u1");
});
it("refuses an unauthenticated caller before syncing anything", async () => {
vi.mocked(requireAuth).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const response = await post(request());
expect(response.status).toBe(401);
expect(syncUserStrava).not.toHaveBeenCalled();
});
it("refuses past the per-user rate limit without touching the sync", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(429);
expect(body.meta?.errorCode).toBe("rate_limited_self");
expect(syncUserStrava).not.toHaveBeenCalled();
expect(checkRateLimit).toHaveBeenCalledWith("strava-sync:u1", 5, 60_000);
});
it("answers 502 generically when the sync throws", async () => {
vi.mocked(syncUserStrava).mockRejectedValue(
new Error("strava said: token abc123 is invalid"),
);
const response = await post(request());
const body = await envelope(response);
expect(response.status).toBe(502);
expect(body.error).toBe("Strava sync failed");
expect(JSON.stringify(body)).not.toContain("abc123");
});
});
+37
View File
@@ -0,0 +1,37 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { syncUserStrava } from "@/lib/strava/sync";
/**
* Manually trigger a Strava sync for the current user (v1.32.28).
*
* Incremental by nature: Strava already carries a real cursor
* (`stravaLastActivityAt` minus an overlap), so a manual run picks up wherever
* the last one stopped. No `fullSync` flag — deep history belongs to the
* connect-time backfill queue, which is self-converging and already gated.
*
* Rate-limited 5/60s per user, matching the hardened Fitbit / Google Health
* routes; Strava's 200-req / 15-min cap leaves no room for a retry loop.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "strava.sync" } });
const rl = await checkRateLimit(`strava-sync:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many sync requests", 429, {
errorCode: "rate_limited_self",
});
}
try {
const imported = await syncUserStrava(user.id);
return apiSuccess({ imported });
} catch {
// Already recorded on the `strava` ledger with its classification. Answer
// generically so no upstream detail rides the response body.
return apiError("Strava sync failed", 502);
}
});
@@ -83,3 +83,26 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => {
expect(invalidatedKeys).toContainEqual(queryKeys.nightscoutStatus());
});
});
/**
* v1.32.28 — sync now, matching the treatment the OAuth cards get.
*/
describe("NightscoutCard — sync-now action", () => {
it("offers the sync action on a connected instance", () => {
statusPayload = {
connected: true,
configured: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render();
expect(html).toContain('data-testid="nightscout-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before an instance is configured", () => {
statusPayload = { connected: false, configured: false };
expect(render()).not.toContain('data-testid="nightscout-sync"');
});
});
@@ -178,3 +178,34 @@ describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)",
expect(html).not.toContain('data-testid="polar-redirect-guide"');
});
});
/**
* v1.32.28 — sync now. The route without the button would be an endpoint with
* no caller; the button is what makes the manual sync a feature rather than an
* API detail. Rendered only once the provider is connected, in the same action
* row as Test connection.
*/
describe("OAuthProviderCard — sync-now action", () => {
it("offers the sync action on a connected provider", () => {
statusPayload = {
connected: true,
configured: true,
available: true,
state: "connected",
lastSuccessAt: null,
lastError: null,
};
const html = render();
expect(html).toContain('data-testid="polar-sync"');
expect(html).toContain("Sync now");
});
it("does not offer it before the provider is connected", () => {
statusPayload = {
connected: false,
configured: true,
available: true,
};
expect(render()).not.toContain('data-testid="polar-sync"');
});
});
@@ -15,6 +15,7 @@ import {
Droplet,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
} from "lucide-react";
@@ -96,6 +97,11 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const { data: status } = useQuery({
queryKey: queryKeys.nightscoutStatus(),
@@ -170,6 +176,39 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
setSaving(false);
}
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — Nightscout has no full-sync arm to call.
async function handleSync() {
setSyncing(true);
setSyncMsg(null);
setSyncMsgType(null);
try {
const res = await apiFetchRaw("/api/nightscout/sync", { method: "POST" });
const json = await res.json();
if (res.ok) {
setSyncMsg(
t("settings.nightscoutSyncResult", { count: json.data.imported }),
);
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({
queryKey: queryKeys.nightscoutStatus(),
});
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncMsg(json.error || t("settings.nightscoutSyncFailed"));
setSyncMsgType("error");
}
} catch {
setSyncMsg(t("settings.nightscoutSyncFailed"));
setSyncMsgType("error");
} finally {
setSyncing(false);
}
}
const pillState = pillStateFor(status);
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
@@ -337,6 +376,22 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
{status?.connected && (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSync}
disabled={syncing}
data-testid="nightscout-sync"
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("settings.nightscoutSync")}
</Button>
<TestConnectionButton
endpoint="/api/nightscout/test"
disabled={!status?.connected}
@@ -373,6 +428,15 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
</AlertDialogContent>
</AlertDialog>
</div>
{syncMsg && (
<p
role="alert"
data-testid="nightscout-sync-result"
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where the glucose
readings now surface — doubles as the "your data is richer"
cue. */}
@@ -15,6 +15,7 @@ import {
ArrowRight,
Link2,
Loader2,
RefreshCw,
Save,
Unlink,
type LucideIcon,
@@ -125,6 +126,11 @@ export function OAuthProviderCard({
const { t } = useTranslations();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(null);
const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>(
null,
);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [credsSaving, setCredsSaving] = useState(false);
@@ -145,6 +151,37 @@ export function OAuthProviderCard({
});
const status = viewModel ?? fetchedStatus;
// Sync now. Same shape as the WHOOP card's incremental arm, minus the
// full-history dialog — none of these providers has a full-sync arm to call.
async function handleSync() {
setSyncing(true);
setSyncMsg(null);
setSyncMsgType(null);
try {
const res = await apiFetchRaw(`/api/${provider}/sync`, {
method: "POST",
});
const json = await res.json();
if (res.ok) {
setSyncMsg(t(`${i18nPrefix}SyncResult`, { count: json.data.imported }));
setSyncMsgType("success");
void invalidateKeys(queryClient, measurementDependentKeys);
queryClient.invalidateQueries({ queryKey: statusQueryKey });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
setSyncMsg(json.error || t(`${i18nPrefix}SyncFailed`));
setSyncMsgType("error");
}
} catch {
setSyncMsg(t(`${i18nPrefix}SyncFailed`));
setSyncMsgType("error");
} finally {
setSyncing(false);
}
}
async function handleSaveCredentials(e: React.FormEvent) {
e.preventDefault();
setCredsSaving(true);
@@ -374,6 +411,22 @@ export function OAuthProviderCard({
{status?.connected ? (
<>
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSync}
disabled={syncing}
data-testid={`${provider}-sync`}
>
{syncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t(`${i18nPrefix}Sync`)}
</Button>
<TestConnectionButton
endpoint={`/api/${provider}/test`}
disabled={!status?.connected}
@@ -411,6 +464,15 @@ export function OAuthProviderCard({
</AlertDialogContent>
</AlertDialog>
</div>
{syncMsg && (
<p
role="alert"
data-testid={`${provider}-sync-result`}
className={`text-sm ${syncMsgType === "success" ? "text-success" : "text-destructive"}`}
>
{syncMsg}
</p>
)}
{/* connect→data loop: a discreet link to where this provider's
readings now surface — doubles as the "your data is richer"
cue. */}
@@ -18,7 +18,15 @@ import { describe, expect, it } from "vitest";
// file into domain registrars; the Nightscout / Polar / Oura queues live in the
// integration-sync registrar. The dead-queue guard follows the wiring there
// (the handler module is still concatenated for the handler-symbol assertions).
//
// v1.32.28 — the four poll-queue NAMES moved again, into
// `integration-poll-queues.ts`, so an OAuth callback can enqueue a connect-time
// first sync without importing the registrar's handler graph. The guard follows
// them: the name declarations are asserted against that module, everything else
// (allQueues membership, cron schedule, handler binding) still against the
// registrar.
const source =
readFileSync(join(__dirname, "..", "integration-poll-queues.ts"), "utf8") +
readFileSync(
join(__dirname, "..", "reminder", "register-integration-sync.ts"),
"utf8",
+27
View File
@@ -0,0 +1,27 @@
/**
* Queue names for the poll-only integration syncs.
*
* Split out of `reminder/register-integration-sync.ts` so a request-path caller
* (an OAuth callback enqueueing the first sync at connect time) can name the
* queue without importing the registrar — which would drag every worker handler
* and its transitive graph into the request bundle. Same shape as
* `STRAVA_BACKFILL_QUEUE`, which the Strava callback already imports from its
* own module.
*
* The registrar remains the single place that creates, schedules, and binds a
* handler to each of these; the values here are its source. A name declared
* here but missing from `allQueues` there is the v1.4.37 dead-queue class, and
* the queue-wiring guards read both files for exactly that reason.
*/
/** Hourly Nightscout CGM poll (:11). */
export const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync";
/** Hourly Polar AccessLink poll (:13). */
export const POLAR_SYNC_QUEUE = "polar-sync";
/** Hourly Oura Cloud poll (:15). */
export const OURA_SYNC_QUEUE = "oura-sync";
/** Hourly Strava poll (:17). */
export const STRAVA_SYNC_QUEUE = "strava-sync";
@@ -77,6 +77,12 @@ import {
type ScheduleEntry,
} from "./registrar-shared";
import { WITHINGS_ECG_SYNC_QUEUE } from "@/lib/jobs/withings-ecg-queue";
import {
NIGHTSCOUT_SYNC_QUEUE,
OURA_SYNC_QUEUE,
POLAR_SYNC_QUEUE,
STRAVA_SYNC_QUEUE,
} from "@/lib/jobs/integration-poll-queues";
import {
WithingsSyncPayload,
WithingsActivitySyncPayload,
@@ -223,8 +229,11 @@ const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON = "26 3 * * *";
// 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";
//
// The four poll-queue NAMES live in `@/lib/jobs/integration-poll-queues` (see
// the import above) so an OAuth callback can enqueue a connect-time first sync
// without importing this registrar and its whole handler graph. Creation,
// scheduling, and handler binding stay here.
const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11
// v1.17.0 (F4) — Polar + Oura OAuth poll sync. Poll-only (one hourly tick per
// provider re-walks every connected user). :13 / :15 stagger off the other
@@ -232,16 +241,13 @@ const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11
// pile up on one boss poll. The queues MUST be registered in `allQueues` below
// or pg-boss never provisions them and the schedule silently no-ops (the
// v1.4.37 dead-queue class).
const POLAR_SYNC_QUEUE = "polar-sync";
const POLAR_SYNC_CRON = "13 * * * *"; // every hour at :13
const OURA_SYNC_QUEUE = "oura-sync";
const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15
// v1.28.x — Strava OAuth poll sync. Poll-only (webhook deferred): one hourly
// tick re-walks every connected user. :17 staggers off the Polar (:13) / Oura
// (:15) ticks so the polls don't pile up on one boss poll. The queue MUST be
// registered in `allQueues` below or pg-boss never provisions it and the
// schedule silently no-ops (the v1.4.37 dead-queue class).
const STRAVA_SYNC_QUEUE = "strava-sync";
const STRAVA_SYNC_CRON = "17 * * * *"; // every hour at :17
// v1.30.x — daily sweep for the native OIDC handoff ledger. Handoff codes are
// single-use + 90s-lived and expiry is enforced at read, so this is pure
+171 -1
View File
@@ -17,6 +17,7 @@ const {
createManyAndReturnMock,
updateMock,
findManyMock,
findFirstMock,
updateManyMock,
emitArrivalMock,
recordSuccessMock,
@@ -42,6 +43,7 @@ const {
createManyAndReturnMock: vi.fn(),
updateMock: vi.fn(),
findManyMock: vi.fn(),
findFirstMock: vi.fn(),
updateManyMock: vi.fn(),
emitArrivalMock: vi.fn(),
recordSuccessMock: vi.fn(),
@@ -64,6 +66,7 @@ vi.mock("@/lib/db", () => ({
createManyAndReturn: createManyAndReturnMock,
update: updateMock,
findMany: findManyMock,
findFirst: findFirstMock,
updateMany: updateManyMock,
},
$transaction: transactionMock,
@@ -112,10 +115,18 @@ vi.mock("../client", async (importOriginal) => {
};
});
import { syncUserOura, upsertOuraMeasurements } from "../sync";
import {
OURA_SYNC_LOOKBACK_DAYS,
OURA_SYNC_MAX_LOOKBACK_DAYS,
resolveOuraLookbackDays,
syncUserOura,
upsertOuraMeasurements,
} from "../sync";
import { OuraApiError } from "../response-classifier";
import { isSyncFailureRecorded } from "@/lib/integrations/status";
const DAY_MS = 24 * 60 * 60 * 1000;
const CONN = {
accessToken: "acc",
refreshToken: "ref",
@@ -178,6 +189,11 @@ beforeEach(() => {
);
updateMock.mockReset().mockResolvedValue({});
findManyMock.mockReset();
// Watermark read: default to a fresh row (yesterday) so every pre-existing
// test keeps the shipped 7-day floor and asserts against an unchanged window.
findFirstMock
.mockReset()
.mockResolvedValue({ measuredAt: new Date(Date.now() - DAY_MS) });
updateManyMock.mockReset().mockResolvedValue({ count: 0 });
emitArrivalMock.mockReset().mockResolvedValue(undefined);
recordSuccessMock.mockReset().mockResolvedValue(undefined);
@@ -706,3 +722,157 @@ describe("upsertOuraMeasurements — exact insertion results", () => {
expect(emitArrivalMock).toHaveBeenCalledWith("u1", [inserted], "oura");
});
});
/**
* v1.32.28 — the lookback window is derived from the newest Oura row we hold
* rather than pinned at seven days.
*
* Two ways days used to be lost. A sync that stayed broken for longer than a
* week came back with a window too narrow to reach the gap. And a ring left
* unsynced produced a run of EMPTY successes, every signal green, after which
* the late-arriving days fell outside the window. Both leave the same
* fingerprint: the newest row stops moving. So one anchor closes both.
*/
describe("syncUserOura — window resolution", () => {
/** Days between the fetch window's start and today, as the query sees it. */
function windowDays(): number {
const query = fetchReadinessMock.mock.calls[0]![1] as {
startDate: string;
endDate: string;
};
const start = Date.parse(`${query.startDate}T00:00:00Z`);
const end = Date.parse(`${query.endDate}T00:00:00Z`);
return Math.round((end - start) / DAY_MS);
}
it("widens the window to cover a ten-day gap", async () => {
getConnMock.mockResolvedValue(CONN);
findFirstMock.mockResolvedValue({
measuredAt: new Date(Date.now() - 10 * DAY_MS),
});
await syncUserOura("u1");
// Ten days stale plus one day of overlap over the last seen day.
expect(windowDays()).toBe(11);
// Every collection shares the one widened window, not just readiness.
for (const fetchMock of [
fetchSleepMock,
fetchActivityMock,
fetchDailySleepMock,
fetchSpo2Mock,
fetchVo2MaxMock,
fetchCardioAgeMock,
fetchResilienceMock,
]) {
expect(fetchMock.mock.calls[0]![1]).toEqual(
fetchReadinessMock.mock.calls[0]![1],
);
}
});
it("keeps the seven-day floor while data is flowing", async () => {
getConnMock.mockResolvedValue(CONN);
findFirstMock.mockResolvedValue({
measuredAt: new Date(Date.now() - DAY_MS),
});
await syncUserOura("u1");
expect(windowDays()).toBe(7);
// One indexed read per tick, and tombstones are excluded from it — a
// soft-deleted row must not pass for a live one.
expect(findFirstMock).toHaveBeenCalledTimes(1);
expect(findFirstMock).toHaveBeenCalledWith({
where: { userId: "u1", source: "OURA", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
});
it("ignores a swept row: a tombstone cannot hold the watermark fresh", async () => {
getConnMock.mockResolvedValue(CONN);
// The sleep sweep soft-deletes rows a revised hypnogram orphaned. The
// resolver's `deletedAt: null` filter means such a row is simply not
// returned, so the newest LIVE row is what answers — here, twelve days old.
findFirstMock.mockImplementation(
async (args: { where: { deletedAt: null } }) => {
expect(args.where.deletedAt).toBeNull();
return { measuredAt: new Date(Date.now() - 12 * DAY_MS) };
},
);
await syncUserOura("u1");
expect(windowDays()).toBe(13);
});
it("takes the full month on a fresh connection with no rows at all", async () => {
getConnMock.mockResolvedValue(CONN);
findFirstMock.mockResolvedValue(null);
await syncUserOura("u1");
expect(windowDays()).toBe(OURA_SYNC_MAX_LOOKBACK_DAYS);
});
it("clamps a ninety-day gap to the month ceiling", async () => {
getConnMock.mockResolvedValue(CONN);
findFirstMock.mockResolvedValue({
measuredAt: new Date(Date.now() - 90 * DAY_MS),
});
await syncUserOura("u1");
expect(windowDays()).toBe(OURA_SYNC_MAX_LOOKBACK_DAYS);
});
it("lets an explicit lookback win without consulting the watermark", async () => {
getConnMock.mockResolvedValue(CONN);
findFirstMock.mockResolvedValue({
measuredAt: new Date(Date.now() - 10 * DAY_MS),
});
await syncUserOura("u1", { lookbackDays: 3 });
expect(windowDays()).toBe(3);
expect(findFirstMock).not.toHaveBeenCalled();
});
it("passes the resolved window to the cycle-phases leg too", async () => {
getConnMock.mockResolvedValue(CONN);
findFirstMock.mockResolvedValue({
measuredAt: new Date(Date.now() - 10 * DAY_MS),
});
await syncUserOura("u1");
expect(fetchCyclePhasesMock).toHaveBeenCalled();
const query = fetchCyclePhasesMock.mock.calls[0]![1] as {
startDate: string;
endDate: string;
};
expect(query).toEqual(fetchReadinessMock.mock.calls[0]![1]);
});
});
describe("resolveOuraLookbackDays", () => {
it("floors at the shipped week and ceilings at the month", async () => {
findFirstMock.mockResolvedValue({ measuredAt: new Date() });
expect(await resolveOuraLookbackDays("u1")).toBe(OURA_SYNC_LOOKBACK_DAYS);
findFirstMock.mockResolvedValue({
measuredAt: new Date(Date.now() - 400 * DAY_MS),
});
expect(await resolveOuraLookbackDays("u1")).toBe(
OURA_SYNC_MAX_LOOKBACK_DAYS,
);
});
it("returns the gap plus one day of overlap in between", async () => {
findFirstMock.mockResolvedValue({
measuredAt: new Date(Date.now() - 20 * DAY_MS),
});
expect(await resolveOuraLookbackDays("u1")).toBe(21);
});
});
+66 -5
View File
@@ -89,11 +89,67 @@ import {
import { OuraApiError, classifyOuraError } from "./response-classifier";
import { syncUserOuraCyclePhases } from "./cycle-sync";
/** Default lookback window (days) for an incremental sync. Oura finalises a
* night's scores hours after wake; 7 days re-fetches a handful of records (the
* upserts are idempotent) and closes any catch-up gap. */
/** Floor of the lookback window (days) for an incremental sync. Oura finalises
* a night's scores hours after wake; 7 days re-fetches a handful of records
* (the upserts are idempotent) and covers the re-score tail. */
export const OURA_SYNC_LOOKBACK_DAYS = 7;
/**
* Ceiling of the lookback window (days). A catch-up tick may widen the window
* up to this many days; beyond it the missing span is history, not an outage,
* and belongs to a backfill rather than an hourly poll.
*
* 30 rather than the ~90 days Oura's own guidance would allow: the whole batch
* still rides ONE `$transaction` with a 60 s timeout (see
* `upsertOuraMeasurements` below), and a 30-day catch-up is roughly four times
* the row count of a normal tick — comfortably inside what that transaction
* handles. Raise it only after the batch write is chunked.
*/
export const OURA_SYNC_MAX_LOOKBACK_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Resolve how far back this tick should reach, anchored on the newest Oura
* measurement we actually hold.
*
* The fixed 7-day window lost days in two ways. A sync that failed for longer
* than a week resumed with a window too narrow to reach the gap, so the missing
* days stayed missing. And a ring left unsynced (Oura's cloud has nothing until
* the app is opened) produced a run of EMPTY successes — every signal green —
* after which the late-arriving days fell outside the window and were never
* imported.
*
* Anchoring on the newest imported row covers both: rows stop arriving whether
* the sync fails or succeeds empty, so the same watermark goes stale in either
* case. It is derived, never stored, so it cannot drift out of step with the
* data the way a persisted cursor can — the watermark IS the data.
*
* `deletedAt: null` matters: the sleep sweep soft-deletes rows a revised
* hypnogram orphaned, and a tombstone must not hold the watermark artificially
* fresh. The `+ 1` day is the overlap buffer over the last seen day. With data
* flowing normally the watermark is under two days old and the clamp floors at
* `OURA_SYNC_LOOKBACK_DAYS`, so the steady-state window and request count are
* exactly what they were before. No watermark at all is a fresh connection:
* take the full ceiling.
*/
export async function resolveOuraLookbackDays(userId: string): Promise<number> {
const newest = await prisma.measurement.findFirst({
where: { userId, source: "OURA", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { measuredAt: true },
});
if (!newest) return OURA_SYNC_MAX_LOOKBACK_DAYS;
const daysSince = Math.floor(
(Date.now() - newest.measuredAt.getTime()) / DAY_MS,
);
return Math.min(
OURA_SYNC_MAX_LOOKBACK_DAYS,
Math.max(OURA_SYNC_LOOKBACK_DAYS, daysSince + 1),
);
}
export function classifyOuraFailure(err: unknown): FailureKind {
return toFailureKind(classifyOuraError(err));
}
@@ -366,6 +422,11 @@ export async function syncUserOura(
const conn = await getOuraConnection(userId);
if (!conn) return 0;
// An explicit window from the caller wins; otherwise derive it from what we
// already hold so a gap widens the window instead of being skipped over.
const lookbackDays =
opts.lookbackDays ?? (await resolveOuraLookbackDays(userId));
let result: OuraFetchResult;
try {
result = await fetchAll(
@@ -373,7 +434,7 @@ export async function syncUserOura(
conn.accessToken,
conn.refreshToken,
conn.refreshTokenCiphertext,
opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS,
lookbackDays,
);
} catch (err) {
// Only a whole-connection failure (a failed token refresh → reauth) reaches
@@ -415,7 +476,7 @@ export async function syncUserOura(
await syncUserOuraCyclePhases(
userId,
freshConn.accessToken,
opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS,
lookbackDays,
);
}
} catch (err) {