mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
33 files changed
+1390
-30
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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=");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user