diff --git a/messages/de.json b/messages/de.json index 06011e984..1d798c935 100644 --- a/messages/de.json +++ b/messages/de.json @@ -189,6 +189,7 @@ "illnessColOnset": "Beginn", "illnessColResolved": "Abgeklungen", "allergiesTitle": "Allergien & Unverträglichkeiten", + "reactionUnreadable": "Reaktion erfasst, konnte aber nicht entschlüsselt werden", "allergiesColSubstance": "Substanz", "allergiesColCategory": "Kategorie", "allergiesColKind": "Art", @@ -209,7 +210,8 @@ "backToDashboard": "Zurück zum Dashboard" }, "offlineBanner": { - "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", + "message": "Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.", + "saveFailed": "Speichern fehlgeschlagen — du bist offline. Bitte versuche es erneut, sobald du wieder online bist.", "showingCached": "Zeigt deine zuletzt synchronisierten Daten." }, "demoBanner": { diff --git a/messages/en.json b/messages/en.json index 2b98c3856..fd06f9b51 100644 --- a/messages/en.json +++ b/messages/en.json @@ -189,6 +189,7 @@ "illnessColOnset": "Onset", "illnessColResolved": "Resolved", "allergiesTitle": "Allergies & intolerances", + "reactionUnreadable": "Reaction recorded but could not be decrypted", "allergiesColSubstance": "Substance", "allergiesColCategory": "Category", "allergiesColKind": "Kind", @@ -209,7 +210,8 @@ "backToDashboard": "Back to dashboard" }, "offlineBanner": { - "message": "No connection — your changes will save once you're back online.", + "message": "No connection — changes can't be saved until you're back online.", + "saveFailed": "Couldn't save — you're offline. Please try again once you're back online.", "showingCached": "Showing your last synced data." }, "demoBanner": { diff --git a/messages/es.json b/messages/es.json index 8b2cfebad..63623d0ac 100644 --- a/messages/es.json +++ b/messages/es.json @@ -189,6 +189,7 @@ "illnessColOnset": "Inicio", "illnessColResolved": "Resuelta", "allergiesTitle": "Alergias e intolerancias", + "reactionUnreadable": "Reacción registrada pero no se pudo descifrar", "allergiesColSubstance": "Sustancia", "allergiesColCategory": "Categoría", "allergiesColKind": "Tipo", @@ -209,7 +210,8 @@ "backToDashboard": "Volver al panel" }, "offlineBanner": { - "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea.", + "message": "Sin conexión — los cambios no se pueden guardar hasta que vuelvas a estar en línea.", + "saveFailed": "No se pudo guardar — estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.", "showingCached": "Mostrando tus últimos datos sincronizados." }, "demoBanner": { diff --git a/messages/fr.json b/messages/fr.json index fc9ffd85b..7cb09bd3b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -189,6 +189,7 @@ "illnessColOnset": "Début", "illnessColResolved": "Résolue", "allergiesTitle": "Allergies et intolérances", + "reactionUnreadable": "Réaction enregistrée mais impossible à déchiffrer", "allergiesColSubstance": "Substance", "allergiesColCategory": "Catégorie", "allergiesColKind": "Type", @@ -209,7 +210,8 @@ "backToDashboard": "Retour au tableau de bord" }, "offlineBanner": { - "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne.", + "message": "Pas de connexion — les modifications ne peuvent pas être enregistrées tant que tu n'es pas de nouveau en ligne.", + "saveFailed": "Enregistrement impossible — tu es hors ligne. Réessaie une fois de nouveau en ligne.", "showingCached": "Affichage de tes dernières données synchronisées." }, "demoBanner": { diff --git a/messages/it.json b/messages/it.json index e8ba8b1c8..21c0b2a5a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -189,6 +189,7 @@ "illnessColOnset": "Inizio", "illnessColResolved": "Risolta", "allergiesTitle": "Allergie e intolleranze", + "reactionUnreadable": "Reazione registrata ma impossibile da decifrare", "allergiesColSubstance": "Sostanza", "allergiesColCategory": "Categoria", "allergiesColKind": "Tipo", @@ -209,7 +210,8 @@ "backToDashboard": "Torna alla dashboard" }, "offlineBanner": { - "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online.", + "message": "Nessuna connessione — le modifiche non possono essere salvate finché non torni online.", + "saveFailed": "Impossibile salvare — sei offline. Riprova quando torni online.", "showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati." }, "demoBanner": { diff --git a/messages/pl.json b/messages/pl.json index c465bd9c2..3f1a6b8b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -189,6 +189,7 @@ "illnessColOnset": "Początek", "illnessColResolved": "Ustąpiła", "allergiesTitle": "Alergie i nietolerancje", + "reactionUnreadable": "Reakcja zapisana, ale nie można jej odszyfrować", "allergiesColSubstance": "Substancja", "allergiesColCategory": "Kategoria", "allergiesColKind": "Rodzaj", @@ -209,7 +210,8 @@ "backToDashboard": "Powrót do panelu" }, "offlineBanner": { - "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online.", + "message": "Brak połączenia — zmian nie można zapisać, dopóki nie wrócisz online.", + "saveFailed": "Nie udało się zapisać — jesteś offline. Spróbuj ponownie, gdy wrócisz online.", "showingCached": "Wyświetlane są ostatnio zsynchronizowane dane." }, "demoBanner": { diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 1ed88a25a..dfc84c48e 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -24,6 +24,10 @@ import { getPersistentFailureThreshold, type IntegrationKey, } from "@/lib/integrations/status"; +import { + getSourceMetricFreshness, + type MetricFreshnessEntry, +} from "@/lib/integrations/metric-freshness"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { hasActivityScope } from "@/lib/withings/client"; @@ -131,6 +135,14 @@ export const GET = apiHandler(async () => { getOuraClientCredentials(user.id).then((c) => !!c), ]); + // F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a + // single metric type has gone silent even while the integration reads green + // ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth + // 500-ing the whole Settings card, so a groupBy hiccup degrades to no data. + const metricFreshness = await getSourceMetricFreshness(user.id).catch( + () => ({}) as Partial>, + ); + const now = Date.now(); return apiSuccess({ @@ -156,6 +168,7 @@ export const GET = apiHandler(async () => { // Null `scope` = legacy connection that predates activity-scope reads. scope: withingsConn?.scope ?? null, hasActivityScope: hasActivityScope(withingsConn?.scope ?? null), + metricFreshness: metricFreshness.withings ?? [], } satisfies IntegrationViewModel & WithingsExtras, { ...moodLogStatus, @@ -183,6 +196,7 @@ export const GET = apiHandler(async () => { ? whoopConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: whoopConn ? !!whoopConn.backfillCompletedAt : null, + metricFreshness: metricFreshness.whoop ?? [], } satisfies IntegrationViewModel & WhoopExtras, { ...fitbitStatus, @@ -197,6 +211,7 @@ export const GET = apiHandler(async () => { ? fitbitConn.tokenExpiresAt.getTime() <= now : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, + metricFreshness: metricFreshness.fitbit ?? [], } satisfies IntegrationViewModel & FitbitExtras, { ...googleHealthStatus, @@ -218,6 +233,7 @@ export const GET = apiHandler(async () => { // user-revoked grant) surfaced from the connection row; the card reads it // to raise a distinct "Reconnect" CTA separate from parked/disconnected. needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false, + metricFreshness: metricFreshness["google-health"] ?? [], } satisfies IntegrationViewModel & GoogleHealthExtras, { ...polarStatus, @@ -231,6 +247,7 @@ export const GET = apiHandler(async () => { hasOwnCredentials: !!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted, + metricFreshness: metricFreshness.polar ?? [], } satisfies IntegrationViewModel & OAuthProviderExtras, { ...ouraStatus, @@ -240,6 +257,7 @@ export const GET = apiHandler(async () => { hasOwnCredentials: !!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted, + metricFreshness: metricFreshness.oura ?? [], } satisfies IntegrationViewModel & OAuthProviderExtras, ], }); @@ -251,6 +269,13 @@ interface IntegrationViewModel { lastSuccessAt: string | null; lastAttemptAt: string | null; lastError: string | null; + /** + * F-SYNC-1 — per-metric-type last-value timestamps for the integration's + * synced measurements. Present on the measurement-backed integrations; + * omitted for moodLog (which writes MoodEntry rows, not Measurements). Lets + * the card flag a silently-dead metric the green integration state hides. + */ + metricFreshness?: MetricFreshnessEntry[]; } interface WithingsExtras { diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index af43498ce..ae5c58a49 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -49,16 +49,19 @@ describe("", () => { }); it("ships the EN copy for `offlineBanner.message`", () => { + // F-OFF-1 — the copy must not over-promise auto-save: offline mutations now + // fail fast (networkMode "always") rather than queue, so the banner states + // the honest thing. const en = loadMessages("en"); expect(en.offlineBanner.message).toBe( - "No connection — your changes will save once you're back online.", + "No connection — changes can't be saved until you're back online.", ); }); it("ships the DE copy for `offlineBanner.message`", () => { const de = loadMessages("de"); expect(de.offlineBanner.message).toBe( - "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", + "Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.", ); }); diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 10ec7fae9..04f1eff50 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -14,8 +14,9 @@ import { useState, type ReactNode, } from "react"; -import { I18nProvider } from "@/lib/i18n/context"; +import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; +import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; import { VersionPoller } from "@/components/version-poller"; import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; @@ -26,6 +27,7 @@ import { restorePersistedQueryCache, startPersistingQueryCache, } from "@/lib/pwa/query-persister"; +import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options"; const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? ""; @@ -177,6 +179,40 @@ function QueryPersistenceBridge() { return null; } +// ── Offline mutation backstop ──────────────────────── + +/** + * F-OFF-1 backstop. With mutations in `always` network mode an offline write + * rejects immediately (rather than pausing forever), so the individual form's + * `onError` fires and shows its own message. This subscriber is the safety net + * for any mutation WITHOUT a call-site error handler: it surfaces one honest, + * de-duplicated toast whenever a mutation errors while the browser is offline, + * so a write can never vanish silently. Gated on `navigator.onLine === false` + * so a normal server-side error (which the form already surfaces) is never + * double-toasted. + */ +function OfflineMutationToaster() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + useEffect(() => { + const cache = queryClient.getMutationCache(); + const unsubscribe = cache.subscribe((event) => { + if ( + event.type === "updated" && + event.action.type === "error" && + typeof navigator !== "undefined" && + navigator.onLine === false + ) { + toast.error(t("offlineBanner.saveFailed"), { + id: "offline-mutation-failed", + }); + } + }); + return unsubscribe; + }, [queryClient, t]); + return null; +} + // ── Root Providers ─────────────────────────────────── export function Providers({ @@ -187,17 +223,7 @@ export function Providers({ initialLocale?: Locale; }) { const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - refetchOnWindowFocus: false, - retry: 1, - }, - }, - }), + () => new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }), ); return ( @@ -206,6 +232,7 @@ export function Providers({ + {children} diff --git a/src/lib/__tests__/doctor-report-allergy-reaction.test.ts b/src/lib/__tests__/doctor-report-allergy-reaction.test.ts new file mode 100644 index 000000000..cff19fd89 --- /dev/null +++ b/src/lib/__tests__/doctor-report-allergy-reaction.test.ts @@ -0,0 +1,57 @@ +/** + * F-CRYPTO-3 — an undecryptable allergy reaction must be distinguishable from a + * genuinely-unset one on a clinician-facing export. The builder used to fail + * SOFT (→ null, silently omitted) where a sibling dose-change note fails CLOSED, + * so a reaction that existed but was unreadable rendered as a blank "—" that a + * clinician reads as "no reaction recorded". `decryptAllergyReaction` now flags + * the unreadable case so the PDF renders an honest marker. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const decryptMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: decryptMock, +})); + +import { decryptAllergyReaction } from "../doctor-report-data"; + +afterEach(() => vi.clearAllMocks()); + +describe("decryptAllergyReaction", () => { + it("returns null / not-unreadable for a genuinely-unset envelope (null)", () => { + expect(decryptAllergyReaction(null)).toEqual({ + reaction: null, + reactionUnreadable: false, + }); + expect(decryptMock).not.toHaveBeenCalled(); + }); + + it("returns null / not-unreadable for an empty envelope", () => { + expect(decryptAllergyReaction(new Uint8Array(0))).toEqual({ + reaction: null, + reactionUnreadable: false, + }); + expect(decryptMock).not.toHaveBeenCalled(); + }); + + it("returns the decrypted reaction on success", () => { + decryptMock.mockReturnValue("hives, swelling"); + expect(decryptAllergyReaction(new Uint8Array([1, 2, 3]))).toEqual({ + reaction: "hives, swelling", + reactionUnreadable: false, + }); + }); + + it("flags reactionUnreadable when the decrypt throws (key gap / GCM corruption)", () => { + decryptMock.mockImplementation(() => { + throw new Error("bad ciphertext"); + }); + // The reaction WAS recorded (non-empty envelope) but cannot be decrypted: + // reaction stays null, but the honest unreadable flag is raised so the + // clinician sees a marker, not a silent blank. + expect(decryptAllergyReaction(new Uint8Array([9, 9, 9]))).toEqual({ + reaction: null, + reactionUnreadable: true, + }); + }); +}); diff --git a/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts b/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts new file mode 100644 index 000000000..20e002dea --- /dev/null +++ b/src/lib/ai/coach/__tests__/self-context-decrypt-logging.test.ts @@ -0,0 +1,85 @@ +/** + * F-CRYPTO-4 — an undecryptable Coach self-context field (allergies / + * conditions) must NOT vanish silently. It still fails closed per field (never + * surfaces ciphertext, never throws into prompt assembly), but a swallowed + * decrypt now emits a wide-event so a key-rotation gap surfaces instead of + * masquerading as "the user never wrote anything". + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { decryptMock, addWarningMock, findUniqueMock } = vi.hoisted(() => ({ + decryptMock: vi.fn(), + addWarningMock: vi.fn(), + findUniqueMock: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { userHealthProfile: { findUnique: findUniqueMock } }, +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: decryptMock, + encryptToBytes: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: addWarningMock }), +})); + +import { getSelfContextForUser } from "../about-me"; + +beforeEach(() => { + decryptMock.mockReset(); + addWarningMock.mockReset(); + findUniqueMock.mockReset(); +}); + +describe("getSelfContextForUser — undecryptable field", () => { + it("fails closed to null AND logs a wide-event on a decrypt failure", async () => { + findUniqueMock.mockResolvedValue({ + aboutMeEncrypted: new Uint8Array([1]), + conditionsEncrypted: null, + allergiesEncrypted: new Uint8Array([2]), // safety-relevant, undecryptable + coachFocusEncrypted: null, + }); + decryptMock.mockImplementation(() => { + throw new Error("bad ciphertext"); + }); + + const ctx = await getSelfContextForUser("u1"); + + // Never surfaces ciphertext; the field is null (fail-closed). + expect(ctx.aboutMe).toBeNull(); + expect(ctx.allergies).toBeNull(); + // But the swallowed decrypt is now visible — one warning per undecryptable + // field (aboutMe + allergies here). + expect(addWarningMock).toHaveBeenCalledTimes(2); + expect(addWarningMock.mock.calls[0][0]).toContain("self-context"); + }); + + it("does not log when the fields are genuinely unset", async () => { + findUniqueMock.mockResolvedValue({ + aboutMeEncrypted: null, + conditionsEncrypted: null, + allergiesEncrypted: null, + coachFocusEncrypted: null, + }); + + const ctx = await getSelfContextForUser("u1"); + expect(ctx).toEqual({ + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, + }); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("logs a wide-event when the DB read itself throws (no silent empty profile)", async () => { + findUniqueMock.mockRejectedValue(new Error("db down")); + + const ctx = await getSelfContextForUser("u1"); + expect(ctx.allergies).toBeNull(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("self-context load failed"), + ); + }); +}); diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index e94dc3bdd..089224d62 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -27,6 +27,7 @@ * Server-only — reads `@/lib/db`. */ import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { SELF_REPORT_FENCE_START, @@ -48,9 +49,18 @@ function decryptOrNull(payload: Uint8Array | null): string | null { try { const text = decryptFromBytes(payload).trim(); return text.length > 0 ? text : null; - } catch { - // Fail closed per field — never surface ciphertext, never throw - // into a prompt-assembly path. + } catch (err) { + // Fail closed per field — never surface ciphertext, never throw into a + // prompt-assembly path. But do NOT swallow silently: a stored-but- + // undecryptable safety-relevant field (allergies / conditions) that + // vanishes from the Coach's self-context with no signal is exactly the + // F-CRYPTO-4 gap. Emit a wide-event so a key-rotation gap surfaces instead + // of masquerading as "the user never wrote anything". + getEvent()?.addWarning( + `coach self-context: field decrypt failed (${ + err instanceof Error ? err.message : String(err) + })`, + ); return null; } } @@ -83,7 +93,15 @@ export async function getSelfContextForUser( allergies: decryptOrNull(row?.allergiesEncrypted ?? null), coachFocus: decryptOrNull(row?.coachFocusEncrypted ?? null), }; - } catch { + } catch (err) { + // Per-field decrypt already fails closed above; this catch is the DB-read + // guard. Surface it (rather than masquerading as an empty profile) so a + // systemic read/key-config failure is visible in wide-events. + getEvent()?.addWarning( + `coach self-context load failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return { aboutMe: null, conditions: null, @@ -390,7 +408,15 @@ export async function getSelfContextTextForUser( ); if (!selfText && !recordsText) return null; return [selfText, recordsText].filter(Boolean).join("\n"); - } catch { + } catch (err) { + // Per-field decrypt fails closed inside the composer; this catch is the + // DB-read guard. Log it so a systemic failure surfaces rather than the + // Coach silently generating guidance without the user's recorded profile. + getEvent()?.addWarning( + `coach self-context text load failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/cycle/custom-symptoms.ts b/src/lib/cycle/custom-symptoms.ts index d4fa21123..7435d731a 100644 --- a/src/lib/cycle/custom-symptoms.ts +++ b/src/lib/cycle/custom-symptoms.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { encrypt, decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import { CUSTOM_SYMPTOM_KEY_PREFIX } from "@/lib/cycle/custom-symptoms-shared"; @@ -52,7 +53,15 @@ export function decryptCustomLabel(encoded: string | null): string | null { if (!encoded) return null; try { return decrypt(encoded); - } catch { + } catch (err) { + // A custom label WAS stored but is undecryptable — fail soft (the caller + // falls back to the generic i18n label) but log it (F-CRYPTO-5) so the loss + // of a user-renamed symptom is not entirely silent. + getEvent()?.addWarning( + `cycle custom-label decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 5dbc4e83c..59fdd1851 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -9,6 +9,7 @@ * (NULL = a plain presence link). */ import { decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import type { CyclePredictionResult } from "@/lib/cycle/types"; import type { CycleDayLog, @@ -73,7 +74,16 @@ function decryptNote(notesEncrypted: string | null): string | null { if (!notesEncrypted) return null; try { return decrypt(notesEncrypted); - } catch { + } catch (err) { + // A note WAS stored but is undecryptable (key-rotation gap / corruption). + // Fail soft to null so one bad row never throws the day-log read, but log + // it (F-CRYPTO-2) so a systemic key gap surfaces instead of silently + // reading as an empty note. + getEvent()?.addWarning( + `cycle day-log note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index f2daa4f1c..edefcee66 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -28,6 +28,7 @@ import type { MeasurementType, } from "@/generated/prisma/client"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { getEvent } from "@/lib/logging/context"; import { readNote } from "@/lib/crypto/note-cipher"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; @@ -343,8 +344,15 @@ export interface DoctorReportData { severity: string | null; /** Status (ACTIVE / INACTIVE / RESOLVED). */ status: string; - /** Decrypted reaction description, or null (unset or key gap). */ + /** Decrypted reaction description, or null (genuinely unset). */ reaction: string | null; + /** + * True when a reaction description WAS stored but could not be decrypted + * (a key-rotation gap / GCM corruption). Distinct from `reaction: null` + * (nothing recorded) so the clinician-facing report shows an honest + * "unreadable" marker rather than a blank that reads as "no reaction". + */ + reactionUnreadable?: boolean; }> | null; /** * v1.27.x — structured family-history records. Reference data, not @@ -765,6 +773,32 @@ export function buildLedgerCompliance( return compliance; } +/** + * Decrypt an allergy reaction envelope for a clinician-facing export, keeping + * "unreadable" distinct from "unset". A genuinely-empty envelope yields + * `{ reaction: null, reactionUnreadable: false }`; a stored-but-undecryptable + * one (key-rotation gap / GCM corruption) yields + * `{ reaction: null, reactionUnreadable: true }` so the report can render an + * honest marker instead of a blank that reads as "no reaction recorded". Pure + * (no logging) so it is unit-testable; the caller logs on the unreadable flag. + */ +export function decryptAllergyReaction(reactionEncrypted: Uint8Array | null): { + reaction: string | null; + reactionUnreadable: boolean; +} { + if (!reactionEncrypted || reactionEncrypted.byteLength === 0) { + return { reaction: null, reactionUnreadable: false }; + } + try { + return { + reaction: decryptFromBytes(reactionEncrypted), + reactionUnreadable: false, + }; + } catch { + return { reaction: null, reactionUnreadable: true }; + } +} + /** * Aggregate the doctor-report payload for a user over a `[start, end]` range. * Pure data assembly — no auth, no rate-limit, no audit. Idempotent. @@ -1477,13 +1511,18 @@ export async function collectDoctorReportData( }, }); const mapped = rows.map((r) => { - let reaction: string | null = null; - if (r.reactionEncrypted && r.reactionEncrypted.byteLength > 0) { - try { - reaction = decryptFromBytes(r.reactionEncrypted); - } catch { - reaction = null; - } + const { reaction, reactionUnreadable } = decryptAllergyReaction( + r.reactionEncrypted, + ); + if (reactionUnreadable) { + // A reaction WAS recorded but is undecryptable (key gap / GCM + // corruption). It is flagged (not silently blanked) so the PDF renders + // an honest "unreadable" marker; log the swallowed decrypt so a + // systemic key-config failure is visible rather than masquerading as + // "no reaction recorded" on a clinician-facing export. + getEvent()?.addWarning( + `doctor-report: allergy reaction decrypt failed for ${userId} (substance=${r.substance})`, + ); } return { substance: r.substance, @@ -1492,6 +1531,7 @@ export async function collectDoctorReportData( severity: r.severity, status: r.status, reaction, + reactionUnreadable, }; }); allergies = mapped.length > 0 ? mapped : null; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 40784dd8d..208f28051 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -1627,7 +1627,11 @@ export function buildDoctorReportPdfDocument( t(`records.allergies.category.${al.category}`), t(`records.allergies.type.${al.type}`), al.severity ? t(`records.allergies.severity.${al.severity}`) : "—", - al.reaction ?? "—", + // A reaction that WAS recorded but could not be decrypted renders an + // honest marker, never a blank "—" that reads as "no reaction recorded". + al.reactionUnreadable + ? t("doctorReport.reactionUnreadable") + : (al.reaction ?? "—"), t(`records.allergies.status.${al.status}`), ]); diff --git a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts index e138ab964..4d5a6a81e 100644 --- a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts +++ b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts @@ -190,6 +190,56 @@ describe("syncUserFitbit — all-403 looks-healthy guard", () => { }); }); +/** A resource sync that HARD-fails its collection (non-403) and, thanks to the + * ported ledger, records + returns 0 instead of rethrowing. */ +async function hardFail500(...args: unknown[]): Promise { + return handleCollectionFetchError( + "test-resource", + String(args[0]), + new FitbitApiError({ + verb: "fetchTest", + classification: "transient", + httpStatus: 500, + reason: "http_500", + }), + ); +} + +describe("syncUserFitbit — hard-fail ledger (F-SYNC-4)", () => { + it("a non-403 hard failure does NOT rethrow and keeps siblings running", async () => { + // Metrics hard-fails (500) but must not abort the cycle: the sibling + // resources still run, and the whole call resolves (no rethrow). + syncUserMetrics.mockImplementation(hardFail500); + syncUserActivity.mockResolvedValue(2); + syncUserSleep.mockResolvedValue(1); + syncUserWorkout.mockResolvedValue(0); + + // Resolves (no rethrow) with the siblings' imports. + const total = await syncUserFitbit("user1"); + expect(total).toBe(3); + + // Every sibling resource still ran despite the metrics hard failure. + expect(syncUserActivity).toHaveBeenCalled(); + expect(syncUserSleep).toHaveBeenCalled(); + expect(syncUserWorkout).toHaveBeenCalled(); + }); + + it("a hard-failed collection fails the cycle: no success stamp, no watermark", async () => { + syncUserMetrics.mockImplementation(hardFail500); + // Others import so the run is not degenerate — only the ledger keeps it honest. + syncUserActivity.mockResolvedValue(4); + + await syncUserFitbit("user1"); + + // The ledger flips anyFailed → success is NOT stamped and the watermark is + // not advanced, so the next tick refetches the broken collection's window. + expect(recordSyncSuccess).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + // The hard failure was recorded on the status ledger. + expect(recordSyncFailure).toHaveBeenCalled(); + }); +}); + describe("syncUserFitbit — single cycle-wide watermark", () => { it("snapshots the incremental start ONCE and threads the SAME watermark to every resource", async () => { syncUserMetrics.mockResolvedValue(1); diff --git a/src/lib/fitbit/sync-activity.ts b/src/lib/fitbit/sync-activity.ts index d9dec20db..4a31e30a1 100644 --- a/src/lib/fitbit/sync-activity.ts +++ b/src/lib/fitbit/sync-activity.ts @@ -109,14 +109,27 @@ export async function syncUserActivity( forbidden = true; break; } - for (const m of resource.map(body)) { - readings.push({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - externalId: externalIdFor(m), - }); + try { + for (const m of resource.map(body)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: externalIdFor(m), + }); + } + } catch (err) { + // A malformed point whose mapper throws routes through the same ledger + // as a fetch failure — record it, short-circuit this resource, and let + // the sibling resources keep syncing. + imported += await handleCollectionFetchError( + resource.verb, + userId, + err, + ); + forbidden = true; + break; } } if (forbidden) continue; diff --git a/src/lib/fitbit/sync-metrics.ts b/src/lib/fitbit/sync-metrics.ts index f05ae2fb8..ec26bce68 100644 --- a/src/lib/fitbit/sync-metrics.ts +++ b/src/lib/fitbit/sync-metrics.ts @@ -103,14 +103,27 @@ export async function syncUserMetrics( forbidden = true; break; } - for (const m of resource.map(body)) { - readings.push({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - externalId: m.fieldTag, - }); + try { + for (const m of resource.map(body)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.fieldTag, + }); + } + } catch (err) { + // A malformed point whose mapper throws routes through the same ledger + // as a fetch failure — record it, short-circuit this metric, and let the + // sibling metrics keep syncing. + imported += await handleCollectionFetchError( + resource.verb, + userId, + err, + ); + forbidden = true; + break; } } if (forbidden) continue; diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index ae3c493cf..8411831df 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -227,12 +227,34 @@ interface RollupDeferTracker { } const rollupDeferStorage = new AsyncLocalStorage(); +/** + * Per-cycle ledger of collection fetches that HARD-failed (non-403). A hard + * failure on one endpoint must not abort its siblings — weight 500ing must not + * suppress body-fat / spo2 / hrv / rhr / respiratory in the same resource — so + * `handleCollectionFetchError` records the failure here and RETURNS instead of + * rethrowing (the old pattern that aborted every endpoint ordered after the bad + * one and stalled the watermark). The orchestrator reads the ledger to keep the + * cycle's verdict honest: any entry ⇒ the run counts as failed, so success is + * NOT stamped and the next tick refetches the window. Ported from Google + * Health's `hardFailStorage`. + */ +interface HardFailTracker { + failures: string[]; +} +const hardFailStorage = new AsyncLocalStorage(); + /** * Single-source the per-resource collection-fetch error handling. A 403 on one - * data class soft-skips it (warn + return 0) so sibling resources still sync; - * anything else records a classified sync failure and rethrows. A soft-skip - * increments the ambient tracker so `syncUserFitbit` can refuse to stamp success - * on an all-403 grant-revoke cycle that imported nothing. + * data class soft-skips it (warn + return 0) so sibling resources still sync; a + * soft-skip increments the ambient tracker so `syncUserFitbit` can refuse to + * stamp success on an all-403 grant-revoke cycle that imported nothing. + * + * Anything else records a classified sync failure, notes the collection on the + * ambient hard-fail ledger, and RETURNS 0 — it does NOT rethrow. Every call site + * sits in a per-collection catch, so returning lets the sibling endpoints in the + * same resource keep fetching (one 500 on weight must not kill body-fat / spo2 / + * hrv / rhr / respiratory), while the ledger still fails the cycle so the + * watermark is not stamped past the broken collection. */ export async function handleCollectionFetchError( resource: string, @@ -248,7 +270,10 @@ export async function handleCollectionFetchError( return 0; } await recordFitbitSyncFailure(userId, err); - throw err; + getEvent()?.addWarning(`fitbit ${resource} failed for ${userId}: ${err}`); + const hardTracker = hardFailStorage.getStore(); + if (hardTracker) hardTracker.failures.push(resource); + return 0; } /** @@ -640,22 +665,29 @@ export async function syncUserFitbit( const tracker: SoftSkipTracker = { count: 0 }; const deferTracker: RollupDeferTracker = { keys: [] }; + const hardFailTracker: HardFailTracker = { failures: [] }; let total = 0; let anyFailed = false; await softSkipStorage.run(tracker, async () => { - await rollupDeferStorage.run(deferTracker, async () => { - for (const fn of resources) { - try { - total += await fn(userId, resourceOpts); - } catch (err) { - anyFailed = true; - getEvent()?.addWarning( - `fitbit ${fn.name} failed for ${userId}: ${err}`, - ); + await hardFailStorage.run(hardFailTracker, async () => { + await rollupDeferStorage.run(deferTracker, async () => { + for (const fn of resources) { + try { + total += await fn(userId, resourceOpts); + } catch (err) { + anyFailed = true; + getEvent()?.addWarning( + `fitbit ${fn.name} failed for ${userId}: ${err}`, + ); + } } - } + }); }); }); + // A collection that hard-failed inside a resource (recorded on the ledger by + // `handleCollectionFetchError` without aborting its siblings) still fails the + // cycle: partial error, no watermark stamp. + if (hardFailTracker.failures.length > 0) anyFailed = true; // On a `fullSync` backfill the per-write inline rollup hook was deferred; the // accumulated touched type-days collapse into ONE range-recompute spanning the diff --git a/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts new file mode 100644 index 000000000..601ea4ddd --- /dev/null +++ b/src/lib/google-health/__tests__/sync-metrics-failsoft.test.ts @@ -0,0 +1,82 @@ +/** + * F-SYNC-3 — the metric MAPPER runs inside a per-type catch, not just the + * fetch. A single malformed point whose `resource.map(point)` throws used to + * escape the METRIC_RESOURCES loop and skip every metric type ordered after it + * (body-fat, spo2, hrv, rhr, respiratory, glucose, temps), also blocking the + * watermark so the bad point refetched hourly and those types stayed dead. + * + * This pins the contract: a throwing mapper on the first type routes through + * `handleCollectionFetchError` and the loop continues to fetch every sibling. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchDataPointsMock, handleErrorMock, mapWeightMock } = vi.hoisted( + () => ({ + fetchDataPointsMock: vi.fn(), + handleErrorMock: vi.fn(async () => 0), + mapWeightMock: vi.fn(() => { + throw new Error("malformed weight point"); + }), + }), +); + +vi.mock("../client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchDataPoints: fetchDataPointsMock, + mapWeight: mapWeightMock, + }; +}); + +vi.mock("../sync", () => ({ + getValidToken: vi.fn(async () => ({ + accessToken: "token", + connection: { id: "c1", googleUserId: "g1" }, + })), + handleCollectionFetchError: handleErrorMock, + upsertGoogleHealthMeasurements: vi.fn(async () => ({ + imported: 0, + touched: [], + })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); + +import { syncUserMetrics } from "../sync-metrics"; + +beforeEach(() => { + handleErrorMock.mockClear(); + mapWeightMock.mockClear(); + // Weight returns one point (whose mapper throws); everything else is empty so + // its real mapper is never exercised. + fetchDataPointsMock + .mockReset() + .mockImplementation(async (_dt: unknown, _token: string, verb: string) => + verb === "fetchWeight" ? [{ any: "point" }] : [], + ); +}); + +describe("syncUserMetrics — a throwing mapper never skips sibling types", () => { + it("routes the map throw through the ledger and keeps fetching the rest", async () => { + await expect(syncUserMetrics("user-1")).resolves.toBe(0); + + // The weight mapper threw and was routed exactly once. + expect(mapWeightMock).toHaveBeenCalled(); + expect(handleErrorMock).toHaveBeenCalledWith( + "fetchWeight", + "user-1", + expect.any(Error), + ); + + // Every metric type ordered AFTER weight still fetched — the loop was not + // aborted by the throw. + const verbs = fetchDataPointsMock.mock.calls.map((c) => c[2]); + expect(verbs).toContain("fetchBodyFat"); + expect(verbs).toContain("fetchSpo2"); + expect(verbs).toContain("fetchRespiratoryRate"); + expect(verbs).toContain("fetchHeartRate"); + }); +}); diff --git a/src/lib/google-health/sync-metrics.ts b/src/lib/google-health/sync-metrics.ts index 408ab7adb..9d366ea38 100644 --- a/src/lib/google-health/sync-metrics.ts +++ b/src/lib/google-health/sync-metrics.ts @@ -146,17 +146,28 @@ export async function syncUserMetrics( continue; } + // The mapper runs INSIDE a per-type catch too: a single malformed point + // whose `resource.map(point)` throws must not escape the METRIC_RESOURCES + // loop and skip every metric type ordered after it (which also blocked the + // watermark, so the bad point refetched hourly and those types stayed dead). + // Route a map throw through the same ledger as a fetch failure — record it, + // fail the cycle, and move on to the next type. const readings: GoogleHealthMeasurementUpsert[] = []; - for (const point of points) { - for (const m of resource.map(point)) { - readings.push({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - externalId: m.fieldTag, - }); + try { + for (const point of points) { + for (const m of resource.map(point)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.fieldTag, + }); + } } + } catch (err) { + imported += await handleCollectionFetchError(resource.verb, userId, err); + continue; } imported += ( await upsertGoogleHealthMeasurements(userId, readings, { diff --git a/src/lib/illness/dto.ts b/src/lib/illness/dto.ts index 02eb7fad2..eb55ce7ec 100644 --- a/src/lib/illness/dto.ts +++ b/src/lib/illness/dto.ts @@ -10,6 +10,7 @@ * Jackson/WURSS intensity (NULL = a plain presence link). */ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; import type { IllnessEpisode, IllnessDayLog, @@ -51,7 +52,14 @@ function decryptNote(noteEncrypted: Uint8Array | null): string | null { if (!noteEncrypted || noteEncrypted.byteLength === 0) return null; try { return decryptFromBytes(noteEncrypted); - } catch { + } catch (err) { + // Undecryptable note (key gap / corruption): fail soft to null but log it + // (F-CRYPTO-2) so a systemic key gap surfaces instead of reading as blank. + getEvent()?.addWarning( + `illness note decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/integrations/__tests__/metric-freshness.test.ts b/src/lib/integrations/__tests__/metric-freshness.test.ts new file mode 100644 index 000000000..b41e54e20 --- /dev/null +++ b/src/lib/integrations/__tests__/metric-freshness.test.ts @@ -0,0 +1,82 @@ +/** + * F-SYNC-1 — per-metric-type last-value freshness. + * + * Pins that the helper distinguishes a silently-dead metric (its newest reading + * frozen in the past) from a genuinely-current one, keyed by integration, from + * one grouped read over the live Measurement rows — the honest signal the + * per-integration "connected · 5 min ago" pill cannot carry. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const groupByMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { groupBy: groupByMock } }, +})); + +import { + getSourceMetricFreshness, + INTEGRATION_MEASUREMENT_SOURCE, +} from "../metric-freshness"; + +beforeEach(() => groupByMock.mockReset()); + +describe("getSourceMetricFreshness", () => { + it("maps grouped source/type rows to per-integration last-seen entries", async () => { + groupByMock.mockResolvedValue([ + { + source: "OURA", + type: "RESPIRATORY_RATE", + _max: { measuredAt: new Date("2026-01-01T00:00:00.000Z") }, + }, + { + source: "OURA", + type: "RECOVERY_SCORE", + _max: { measuredAt: new Date("2026-07-07T00:00:00.000Z") }, + }, + { + source: "WITHINGS", + type: "WEIGHT", + _max: { measuredAt: new Date("2026-07-06T00:00:00.000Z") }, + }, + ]); + + const result = await getSourceMetricFreshness("u1"); + + // The dead metric (respiratory rate, frozen in January) is visible as a + // distinct, older timestamp next to the current recovery score — exactly + // the "broken pipe vs healthy-idle" distinction the pill can't express. + expect(result.oura).toEqual([ + { type: "RECOVERY_SCORE", lastSeenAt: "2026-07-07T00:00:00.000Z" }, + { type: "RESPIRATORY_RATE", lastSeenAt: "2026-01-01T00:00:00.000Z" }, + ]); + expect(result.withings).toEqual([ + { type: "WEIGHT", lastSeenAt: "2026-07-06T00:00:00.000Z" }, + ]); + // Only the sync sources are queried. + expect(groupByMock.mock.calls[0][0].where.source.in).toEqual( + Object.values(INTEGRATION_MEASUREMENT_SOURCE), + ); + expect(groupByMock.mock.calls[0][0].where.deletedAt).toBeNull(); + }); + + it("skips rows with no reading and unmapped sources", async () => { + groupByMock.mockResolvedValue([ + { source: "OURA", type: "VO2_MAX", _max: { measuredAt: null } }, + { + source: "MANUAL", + type: "WEIGHT", + _max: { measuredAt: new Date("2026-07-01T00:00:00.000Z") }, + }, + ]); + + const result = await getSourceMetricFreshness("u1"); + // A null max (no rows) yields no entry; MANUAL isn't a sync integration. + expect(result.oura).toBeUndefined(); + expect(Object.keys(result)).toHaveLength(0); + }); + + it("returns an empty map when the user has no synced measurements", async () => { + groupByMock.mockResolvedValue([]); + expect(await getSourceMetricFreshness("u1")).toEqual({}); + }); +}); diff --git a/src/lib/integrations/metric-freshness.ts b/src/lib/integrations/metric-freshness.ts new file mode 100644 index 000000000..d70ac2cc4 --- /dev/null +++ b/src/lib/integrations/metric-freshness.ts @@ -0,0 +1,90 @@ +/** + * F-SYNC-1 — per-metric-type freshness (the smallest honest signal). + * + * Freshness was tracked only per-integration (`IntegrationStatus.lastSuccessAt` + * / a connection's `lastSyncedAt`). A provider that returns HTTP 200 with an + * empty body for ONE data type produces zero rows, throws nothing, and the + * other types in the same cycle stamp the whole integration green — so the + * Settings pill reads "connected · 5 min ago" while, say, respiratory rate has + * been dead since launch, and nothing distinguishes "broken pipe" from "healthy + * but idle". + * + * This computes the last-value timestamp per `(source, type)` straight from the + * `Measurement` table (no schema change, no migration) so a caller can surface + * per-metric liveness honestly: a metric whose newest reading is frozen weeks in + * the past is visibly distinct from one that is genuinely current. The coarser + * "is this stale for THIS metric's expected cadence" classification (per-type + * thresholds / an expected-cadence probe cron) is the deferred fuller model; + * this exposes the raw honest timestamp the UI renders as "last seen …". + */ +import { prisma } from "@/lib/db"; +import type { MeasurementSource } from "@/generated/prisma/client"; +import type { IntegrationKey } from "./status"; + +/** + * The `MeasurementSource` each sync integration's rows carry. `moodlog` is + * absent — it writes `MoodEntry` rows, not `Measurement` rows, so it has no + * per-metric measurement freshness. + */ +export const INTEGRATION_MEASUREMENT_SOURCE: Partial< + Record +> = { + withings: "WITHINGS", + whoop: "WHOOP", + fitbit: "FITBIT", + nightscout: "NIGHTSCOUT", + polar: "POLAR", + oura: "OURA", + "google-health": "GOOGLE_HEALTH", +}; + +/** Newest recorded reading for one `(source, type)` pair. */ +export interface MetricFreshnessEntry { + /** The `MeasurementType` (e.g. "RESPIRATORY_RATE"). */ + type: string; + /** ISO timestamp of the newest (non-deleted) reading for this metric. */ + lastSeenAt: string; +} + +/** + * Compute per-`(integration, type)` last-value timestamps for the sync + * integrations, keyed by `IntegrationKey`. One grouped query over the live + * `Measurement` rows; a source with no rows simply has no entry. Types are + * sorted alphabetically for a stable wire shape. + */ +export async function getSourceMetricFreshness( + userId: string, +): Promise>> { + const sources = Object.values(INTEGRATION_MEASUREMENT_SOURCE); + const grouped = await prisma.measurement.groupBy({ + by: ["source", "type"], + where: { userId, deletedAt: null, source: { in: sources } }, + _max: { measuredAt: true }, + }); + + // Invert the source→key map once so each grouped row resolves its integration. + const sourceToKey = new Map( + ( + Object.entries(INTEGRATION_MEASUREMENT_SOURCE) as Array< + [IntegrationKey, MeasurementSource] + > + ).map(([key, source]) => [source, key]), + ); + + const out: Partial> = {}; + for (const row of grouped) { + const lastSeen = row._max.measuredAt; + if (!lastSeen) continue; + const key = sourceToKey.get(row.source); + if (!key) continue; + (out[key] ??= []).push({ + type: row.type, + lastSeenAt: lastSeen.toISOString(), + }); + } + + for (const key of Object.keys(out) as IntegrationKey[]) { + out[key]!.sort((a, b) => a.type.localeCompare(b.type)); + } + return out; +} diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index becfca88d..f3d224894 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -26,7 +26,7 @@ */ import { unlinkSync } from "node:fs"; import { PrismaClient } from "@/generated/prisma/client"; -import { toJson } from "@/lib/db"; +import { buildSessionOptions, getPoolMax, toJson } from "@/lib/db"; import { PrismaPg } from "@prisma/adapter-pg"; import type { Job } from "pg-boss"; @@ -61,8 +61,15 @@ export interface AppleHealthImportPayload { let workerPrismaSingleton: PrismaClient | null = null; function getWorkerPrisma(): PrismaClient { if (!workerPrismaSingleton) { + // Inherit the web client's pool ceiling + per-session statement timeouts + // (`src/lib/db.ts`). The timeout is per-statement, not per-job, so the + // long-running bulk import stays safe while a single wedged UPSERT can no + // longer pin a worker connection indefinitely. + const sessionOptions = buildSessionOptions(); const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL!, + max: getPoolMax(), + ...(sessionOptions ? { options: sessionOptions } : {}), }); workerPrismaSingleton = new PrismaClient({ adapter }); } diff --git a/src/lib/jobs/reminder/__tests__/shared.test.ts b/src/lib/jobs/reminder/__tests__/shared.test.ts new file mode 100644 index 000000000..feecd51c3 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/shared.test.ts @@ -0,0 +1,107 @@ +/** + * F-DB-1 — the worker Prisma client must inherit the same pool ceiling + + * session timeouts as the web client (`src/lib/db.ts`). + * + * Before this fix the worker adapter was built with a bare + * `{ connectionString }`, so a pathological nightly drain on a heavy tenant + * could run unbounded, pin a connection indefinitely, and wedge the shared + * worker pool. This test pins the contract: `getWorkerPrisma()` constructs its + * `PrismaPg` adapter with `max` from `getPoolMax()` and the `options` startup + * string from `buildSessionOptions()`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const adapterCtor = vi.fn(); +const clientCtor = vi.fn(); + +vi.mock("@prisma/adapter-pg", () => ({ + PrismaPg: class { + constructor(...args: unknown[]) { + adapterCtor(...args); + } + }, +})); + +vi.mock("@/generated/prisma/client", () => ({ + PrismaClient: class { + constructor(...args: unknown[]) { + clientCtor(...args); + } + }, +})); + +describe("getWorkerPrisma", () => { + beforeEach(() => { + vi.resetModules(); + adapterCtor.mockClear(); + clientCtor.mockClear(); + process.env.DATABASE_URL = "postgres://user:pass@localhost:5432/db"; + delete process.env.DATABASE_POOL_MAX; + delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; + }); + + afterEach(() => { + delete process.env.DATABASE_POOL_MAX; + delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; + }); + + it("builds the adapter with a pool cap and session timeouts", async () => { + const { getWorkerPrisma } = await import("../shared"); + // Importing ../shared pulls in src/lib/db.ts, which constructs the web + // client's adapter at module load. Clear so we count only the worker's. + adapterCtor.mockClear(); + clientCtor.mockClear(); + getWorkerPrisma(); + + expect(adapterCtor).toHaveBeenCalledTimes(1); + const config = adapterCtor.mock.calls[0][0] as { + connectionString: string; + max: number; + options?: string; + }; + expect(config.connectionString).toBe( + "postgres://user:pass@localhost:5432/db", + ); + // Default pool ceiling — must not fall back to the pg library default of 10. + expect(config.max).toBe(20); + // Both session timeouts applied at connection establishment. + expect(config.options).toContain("-c statement_timeout=60000"); + expect(config.options).toContain( + "-c idle_in_transaction_session_timeout=60000", + ); + }); + + it("honours the shared env knobs", async () => { + process.env.DATABASE_POOL_MAX = "12"; + process.env.DATABASE_STATEMENT_TIMEOUT_MS = "30000"; + const { getWorkerPrisma } = await import("../shared"); + adapterCtor.mockClear(); + getWorkerPrisma(); + + const config = adapterCtor.mock.calls[0][0] as { + max: number; + options?: string; + }; + expect(config.max).toBe(12); + expect(config.options).toContain("-c statement_timeout=30000"); + }); + + it("omits the options string when the timeout is disabled", async () => { + process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0"; + const { getWorkerPrisma } = await import("../shared"); + adapterCtor.mockClear(); + getWorkerPrisma(); + + const config = adapterCtor.mock.calls[0][0] as { options?: string }; + expect(config.options).toBeUndefined(); + }); + + it("reuses a single client across calls", async () => { + const { getWorkerPrisma } = await import("../shared"); + clientCtor.mockClear(); + const a = getWorkerPrisma(); + const b = getWorkerPrisma(); + expect(a).toBe(b); + expect(clientCtor).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/jobs/reminder/shared.ts b/src/lib/jobs/reminder/shared.ts index 05cd9ba58..079ab63f7 100644 --- a/src/lib/jobs/reminder/shared.ts +++ b/src/lib/jobs/reminder/shared.ts @@ -6,6 +6,7 @@ */ import { PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; +import { buildSessionOptions, getPoolMax } from "@/lib/db"; export function parseTimeToMinutes(value: string): number { const [h, m] = value.split(":").map(Number); @@ -36,7 +37,22 @@ let workerPrisma: PrismaClient | null = null; export function getWorkerPrisma(): PrismaClient { if (!workerPrisma) { - const adapter = new PrismaPg({ connectionString: DATABASE_URL }); + // Apply the same pool ceiling + session timeouts the web client uses + // (`src/lib/db.ts`). Without them the worker client ran unbounded: a + // pathological nightly drain on a heavy tenant could pin a connection + // indefinitely and wedge the shared worker pool. `getPoolMax()` caps the + // slot count; `buildSessionOptions()` applies `statement_timeout` + + // `idle_in_transaction_session_timeout` at connection establishment so + // every worker session is timeout-bounded from its first query. Both share + // the same `DATABASE_POOL_MAX` / `DATABASE_STATEMENT_TIMEOUT_MS` env knobs + // as the web tier, and the web + worker containers share one Postgres so a + // single ceiling covers both pools. + const sessionOptions = buildSessionOptions(); + const adapter = new PrismaPg({ + connectionString: DATABASE_URL, + max: getPoolMax(), + ...(sessionOptions ? { options: sessionOptions } : {}), + }); workerPrisma = new PrismaClient({ adapter }); } return workerPrisma; diff --git a/src/lib/labs/biomarker-store.ts b/src/lib/labs/biomarker-store.ts index dd2f3cdf6..b6cefe5e1 100644 --- a/src/lib/labs/biomarker-store.ts +++ b/src/lib/labs/biomarker-store.ts @@ -13,6 +13,7 @@ import { Buffer } from "node:buffer"; import { decrypt, encrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; /** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */ export function encryptContextToBytes( @@ -40,7 +41,15 @@ export function decryptContextSoft(buf: Uint8Array | null): string | null { if (!buf) return null; try { return decryptContextFromBytes(buf); - } catch { + } catch (err) { + // Undecryptable context (key gap / corruption): fail soft to null so one + // bad row never 500s the catalog list, but log it (F-CRYPTO-2) so a + // systemic key gap surfaces instead of masking as an empty context. + getEvent()?.addWarning( + `biomarker context decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts index 7a494c151..c23d7030a 100644 --- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -158,3 +158,56 @@ describe("readDailySeries — long-range tier step-up", () => { expect(mocks.findMany).toHaveBeenCalled(); }); }); + +describe("readDailySeries — rollup read throw falls back to live SQL (F-DB-2)", () => { + it("a rollup-table throw on a normal window degrades to the live date_trunc aggregate, not a 500", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 90 * DAY_MS); + + // The primary rollup read throws (statement_timeout / deadlock / reset). + mocks.findMany + .mockReset() + .mockRejectedValue(new Error("statement timeout")); + // Live fallback serves the tile. + mocks.queryRaw.mockResolvedValueOnce([ + { + type: "WEIGHT", + bucket_start: new Date("2026-06-10T00:00:00.000Z"), + avg: 80.5, + cnt: 2, + }, + ]); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + // The read did NOT throw — it fell through to live SQL and served a row. + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ type: "WEIGHT", value: 80.5, count: 2 }); + expect(mocks.queryRaw).toHaveBeenCalled(); + }); + + it("a tiered-reader throw on a long window falls through to the daily path", async () => { + const to = new Date("2026-06-21T00:00:00.000Z"); + const from = new Date(to.getTime() - 3650 * DAY_MS); + mocks.readTieredRollupSeries.mockRejectedValueOnce(new Error("deadlock")); + + const result = await readDailySeries({ + userId: "u", + type: "WEIGHT", + from, + to, + priorityJson: null, + }); + + // The daily DAY-rollup read still ran as the fallback (the default findMany + // returns 3 rows), so the throw did not 500 the read. + expect(mocks.findMany).toHaveBeenCalled(); + expect(result).toHaveLength(3); + }); +}); diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 98cafad59..7461b8dab 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -76,17 +76,32 @@ export async function readDailySeries(opts: { (to.getTime() - from.getTime()) / 86_400_000, ); if (windowDaysFull > cap) { - const tiered = await readTieredRollupSeries({ - userId, - type, - // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier - // reads the REQUESTED window (which may be entirely historic), not a - // trailing "to now" slice. `windowDaysFull` still gates entry above and - // drives the tier width inside the reader. - from, - to, - priorityJson, - }); + // A rollup-table throw here (statement_timeout, deadlock, connection reset) + // must not 500 the whole read — treat it as a coverage miss and fall + // through to the daily path (which itself falls back to live SQL). + let tiered: Awaited> = null; + try { + tiered = await readTieredRollupSeries({ + userId, + type, + // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier + // reads the REQUESTED window (which may be entirely historic), not a + // trailing "to now" slice. `windowDaysFull` still gates entry above and + // drives the tier width inside the reader. + from, + to, + priorityJson, + }); + } catch (err) { + annotate({ + meta: { + tiered_series_read_threw: true, + tiered_series_read_error: + err instanceof Error ? err.message : String(err), + type, + }, + }); + } if (tiered && tiered.rows.length > 0) { return tiered.rows; } @@ -129,7 +144,25 @@ export async function readDailySeries(opts: { priorityJson, ).slice(0, cap); - const rollupRows = await readRollup(); + // F-DB-2 — the primary rollup read is wrapped: a transient rollup-table error + // (statement_timeout, deadlock, connection reset) must NOT 500 the tile when + // the live table can still serve it. Treat a throw as a coverage miss and fall + // straight through to the live `date_trunc` aggregate — the same posture the + // batched series reader already takes, so the single-type route and the batch + // route no longer diverge on a rollup hiccup. + let rollupRows: Awaited>; + try { + rollupRows = await readRollup(); + } catch (err) { + annotate({ + meta: { + rollup_read_threw: true, + rollup_read_error: err instanceof Error ? err.message : String(err), + type, + }, + }); + return readLiveDaily({ userId, type, from, to, cap, priorityJson }); + } // Coverage-mismatch probe + inline fold (identical to the route). const windowMs = to.getTime() - from.getTime(); diff --git a/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts b/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts new file mode 100644 index 000000000..58db22f85 --- /dev/null +++ b/src/lib/mood/__tests__/custom-label-decrypt-logging.test.ts @@ -0,0 +1,49 @@ +/** + * F-CRYPTO-5 — an undecryptable custom mood label fails soft (the caller falls + * back to the generic i18n label) but must NOT be entirely silent: a swallowed + * decrypt now emits a wide-event so the loss of a user-renamed tag surfaces. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { decryptMock, addWarningMock } = vi.hoisted(() => ({ + decryptMock: vi.fn(), + addWarningMock: vi.fn(), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: decryptMock, + encrypt: vi.fn(), +})); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: addWarningMock }), +})); + +import { decryptCustomLabel } from "../custom-tags"; + +beforeEach(() => { + decryptMock.mockReset(); + addWarningMock.mockReset(); +}); + +describe("decryptCustomLabel", () => { + it("returns null without logging for a genuinely-unset label", () => { + expect(decryptCustomLabel(null)).toBeNull(); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("returns the decrypted label on success", () => { + decryptMock.mockReturnValue("Morning walk"); + expect(decryptCustomLabel("cipher")).toBe("Morning walk"); + expect(addWarningMock).not.toHaveBeenCalled(); + }); + + it("fails soft to null AND logs a wide-event when the decrypt throws", () => { + decryptMock.mockImplementation(() => { + throw new Error("bad key"); + }); + expect(decryptCustomLabel("cipher")).toBeNull(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("custom-label decrypt failed"), + ); + }); +}); diff --git a/src/lib/mood/custom-tags.ts b/src/lib/mood/custom-tags.ts index 317435128..efc5d52fc 100644 --- a/src/lib/mood/custom-tags.ts +++ b/src/lib/mood/custom-tags.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { z } from "zod/v4"; import { encrypt, decrypt } from "@/lib/crypto"; +import { getEvent } from "@/lib/logging/context"; import { MOOD_TAG_ICON_NAMES } from "@/lib/mood/icon-catalog"; /** @@ -77,7 +78,15 @@ export function decryptCustomLabel(encoded: string | null): string | null { if (!encoded) return null; try { return decrypt(encoded); - } catch { + } catch (err) { + // A custom label WAS stored but is undecryptable — fail soft (the caller + // falls back to the generic i18n label) but log it (F-CRYPTO-5) so the loss + // of a user-renamed tag is not entirely silent. + getEvent()?.addWarning( + `mood custom-label decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index e452322f1..2d4f52a2e 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -338,7 +338,79 @@ describe("syncUserOura", () => { reason: "http_403", }), ); - await expect(syncUserOura("u1")).rejects.toBeInstanceOf(OuraApiError); + // A 403 is a per-collection hard failure now (isolated, not a whole-batch + // reject): it records a partial failure and does NOT trigger a refresh. + await syncUserOura("u1"); + expect(refreshMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura" }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); + + it("isolates one bad collection — siblings still import (F-SYNC-2)", async () => { + getConnMock.mockResolvedValue(CONN); + // Readiness endpoint is flaky (500), but sleep-score + spo2 return data. + fetchReadinessMock.mockRejectedValue( + new OuraApiError({ + verb: "fetchReadiness", + classification: "transient", + httpStatus: 500, + reason: "http_500", + }), + ); + fetchDailySleepMock.mockResolvedValue([ + { id: "s", day: "2026-06-10", score: 80 }, + ]); + fetchSpo2Mock.mockResolvedValue([ + { id: "o", day: "2026-06-10", spo2_percentage: { average: 97 } }, + ]); + + const imported = await syncUserOura("u1"); + + // The healthy collections still wrote their rows — the source is not blanked. + expect(imported).toBe(2); + const written = upsertMock.mock.calls.map( + (c) => c[0].where.userId_type_source_externalId.type, + ); + expect(written).toContain("SLEEP_SCORE"); + expect(written).toContain("OXYGEN_SATURATION"); + // The cycle is still marked failed (partial) so freshness stays honest. + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ integration: "oura" }), + ); + expect(recordSuccessMock).not.toHaveBeenCalled(); expect(refreshMock).not.toHaveBeenCalled(); }); + + it("isolates a throwing mapper — one malformed record does not blank siblings (F-SYNC-2)", async () => { + getConnMock.mockResolvedValue(CONN); + // A malformed readiness record whose mapper throws must not reject the + // whole batch (the old Promise.all failure mode). + fetchReadinessMock.mockReturnValue( + Promise.resolve([ + new Proxy( + {}, + { + get() { + throw new Error("malformed readiness point"); + }, + }, + ), + ]), + ); + fetchSpo2Mock.mockResolvedValue([ + { id: "o", day: "2026-06-10", spo2_percentage: { average: 96 } }, + ]); + + const imported = await syncUserOura("u1"); + + expect(imported).toBe(1); + const written = upsertMock.mock.calls.map( + (c) => c[0].where.userId_type_source_externalId.type, + ); + expect(written).toContain("OXYGEN_SATURATION"); + expect(recordFailureMock).toHaveBeenCalled(); + expect(recordSuccessMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 363b3c485..80ddc2834 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -114,11 +114,42 @@ function toUpsert( })); } +/** One Oura collection: its ledger name and a self-contained fetch+map closure + * that resolves to the collection's mapped upserts. Each closure owns its own + * client call and mapper so a throw is contained to this one collection. */ +interface OuraCollectionSpec { + name: string; + collect: (token: string) => Promise; +} + +/** A single collection that failed to fetch/map, carried so the caller can + * classify + record the failure without blanking the collections that did + * succeed. */ +export interface OuraCollectionFailure { + name: string; + err: unknown; +} + +export interface OuraFetchResult { + readings: OuraMeasurementUpsert[]; + failures: OuraCollectionFailure[]; +} + /** * Fetch every Oura daily collection for a user with a single reactive - * refresh-on-401 retry. Returns the raw mapped readings (not yet upserted). - * Throws a classified `OuraApiError` on a hard failure so the caller records - * the ledger entry. + * refresh-on-401 retry, ISOLATED per collection. Unlike a bare `Promise.all`, + * one flaky endpoint or one throwing mapper no longer rejects the whole batch + * (which used to blank readiness, sleep, activity, spo2 all at once). Each + * collection is settled independently (`Promise.allSettled`); the ones that + * succeed import, the ones that throw are returned as `failures` for the caller + * to record — mirroring Google Health's / Fitbit's per-collection hard-fail + * ledger. + * + * The reactive refresh is preserved: because every collection shares one access + * token, an expired token 401s all of them together; a single refresh is done + * once and ONLY the failed collections are retried with the rotated token. A + * failed refresh (`invalid_grant`) still throws so the caller parks the whole + * connection at `reauth_required`. */ async function fetchAll( userId: string, @@ -126,72 +157,132 @@ async function fetchAll( refreshToken: string, refreshTokenCiphertext: string, lookbackDays: number, -): Promise { +): Promise { const now = new Date(); const start = new Date(now.getTime() - lookbackDays * 24 * 60 * 60 * 1000); const query = { startDate: ymd(start), endDate: ymd(now) }; - const run = async (token: string): Promise => { - const [ - readiness, - sleeps, - activities, - dailySleep, - spo2, - vo2max, - cardioAge, - resilience, - ] = await Promise.all([ - fetchReadiness(token, query), - fetchSleep(token, query), - fetchDailyActivity(token, query), - fetchDailySleep(token, query), - fetchDailySpo2(token, query), - fetchVo2Max(token, query), - fetchCardiovascularAge(token, query), - fetchResilience(token, query), - ]); - const out: OuraMeasurementUpsert[] = []; - for (const r of readiness) - out.push(...toUpsert(mapReadiness(r), "readiness")); - for (const s of sleeps) out.push(...toUpsert(mapSleep(s), "sleep")); - for (const a of activities) - out.push(...toUpsert(mapDailyActivity(a), "activity")); - for (const d of dailySleep) - out.push(...toUpsert(mapDailySleep(d), "daily_sleep")); - for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2")); - for (const v of vo2max) out.push(...toUpsert(mapVo2Max(v), "vo2max")); - for (const c of cardioAge) - out.push(...toUpsert(mapCardiovascularAge(c), "cardio_age")); - for (const r of resilience) - out.push(...toUpsert(mapResilience(r), "resilience")); - return out; + const collections: OuraCollectionSpec[] = [ + { + name: "readiness", + collect: async (t) => + (await fetchReadiness(t, query)).flatMap((r) => + toUpsert(mapReadiness(r), "readiness"), + ), + }, + { + name: "sleep", + collect: async (t) => + (await fetchSleep(t, query)).flatMap((s) => + toUpsert(mapSleep(s), "sleep"), + ), + }, + { + name: "activity", + collect: async (t) => + (await fetchDailyActivity(t, query)).flatMap((a) => + toUpsert(mapDailyActivity(a), "activity"), + ), + }, + { + name: "daily_sleep", + collect: async (t) => + (await fetchDailySleep(t, query)).flatMap((d) => + toUpsert(mapDailySleep(d), "daily_sleep"), + ), + }, + { + name: "spo2", + collect: async (t) => + (await fetchDailySpo2(t, query)).flatMap((s) => + toUpsert(mapDailySpo2(s), "spo2"), + ), + }, + { + name: "vo2max", + collect: async (t) => + (await fetchVo2Max(t, query)).flatMap((v) => + toUpsert(mapVo2Max(v), "vo2max"), + ), + }, + { + name: "cardio_age", + collect: async (t) => + (await fetchCardiovascularAge(t, query)).flatMap((c) => + toUpsert(mapCardiovascularAge(c), "cardio_age"), + ), + }, + { + name: "resilience", + collect: async (t) => + (await fetchResilience(t, query)).flatMap((r) => + toUpsert(mapResilience(r), "resilience"), + ), + }, + ]; + + // Run a set of collections isolated from one another. A rejection is captured + // per-collection, never propagated, so siblings still resolve. + const attempt = async ( + token: string, + specs: OuraCollectionSpec[], + ): Promise<{ + readings: OuraMeasurementUpsert[]; + failed: OuraCollectionSpec[]; + failures: OuraCollectionFailure[]; + auth401: boolean; + }> => { + const settled = await Promise.allSettled( + specs.map((spec) => spec.collect(token)), + ); + const readings: OuraMeasurementUpsert[] = []; + const failed: OuraCollectionSpec[] = []; + const failures: OuraCollectionFailure[] = []; + let auth401 = false; + settled.forEach((res, i) => { + const spec = specs[i]!; + if (res.status === "fulfilled") { + readings.push(...res.value); + } else { + const err = res.reason; + if (err instanceof OuraApiError && err.httpStatus === 401) + auth401 = true; + failed.push(spec); + failures.push({ name: spec.name, err }); + } + }); + return { readings, failed, failures, auth401 }; }; - try { - return await run(accessToken); - } catch (err) { - // Reactive refresh: a 401 means the access token expired. Refresh once - // (rotating both tokens) and retry. A 403 / other error is NOT a refresh - // case — rethrow so the caller classifies it. - if (!(err instanceof OuraApiError) || err.httpStatus !== 401) throw err; - - const creds = await getOuraClientCredentials(userId); - if (!creds) throw err; - - const rotated = await refreshAccessToken(refreshToken, creds); - // Compare-and-swap persist: on a lost race against a concurrent sync this - // returns the peer's freshly rotated access token rather than the (now - // invalidated) one we just minted, so neither sync parks the connection. - const usableToken = await storeOuraTokens( - userId, - rotated.access_token, - rotated.refresh_token, - refreshTokenCiphertext, - ); - if (!usableToken) throw err; - return run(usableToken); + const first = await attempt(accessToken, collections); + if (!first.auth401) { + return { readings: first.readings, failures: first.failures }; } + + // Reactive refresh: the access token expired (a 401 on the shared token). + // Refresh once (rotating both tokens) and retry ONLY the collections that + // failed. A failed refresh throws so the caller parks reauth_required. + const creds = await getOuraClientCredentials(userId); + if (!creds) return { readings: first.readings, failures: first.failures }; + + const rotated = await refreshAccessToken(refreshToken, creds); + // Compare-and-swap persist: on a lost race against a concurrent sync this + // returns the peer's freshly rotated access token rather than the (now + // invalidated) one we just minted, so neither sync parks the connection. + const usableToken = await storeOuraTokens( + userId, + rotated.access_token, + rotated.refresh_token, + refreshTokenCiphertext, + ); + if (!usableToken) + return { readings: first.readings, failures: first.failures }; + + const retry = await attempt(usableToken, first.failed); + return { + readings: [...first.readings, ...retry.readings], + failures: retry.failures, + }; } /** @@ -206,9 +297,9 @@ export async function syncUserOura( const conn = await getOuraConnection(userId); if (!conn) return 0; - let readings: OuraMeasurementUpsert[]; + let result: OuraFetchResult; try { - readings = await fetchAll( + result = await fetchAll( userId, conn.accessToken, conn.refreshToken, @@ -216,6 +307,8 @@ export async function syncUserOura( opts.lookbackDays ?? OURA_SYNC_LOOKBACK_DAYS, ); } catch (err) { + // Only a whole-connection failure (a failed token refresh → reauth) reaches + // here now; per-collection failures are captured on `result.failures`. await recordSyncFailure({ userId, integration: "oura", @@ -229,7 +322,37 @@ export async function syncUserOura( throw err; } - const imported = await upsertOuraMeasurements(userId, readings); + // Import everything the healthy collections returned regardless of whether a + // sibling collection failed — one bad collection must not blank the source. + const imported = await upsertOuraMeasurements(userId, result.readings); + + if (result.failures.length > 0) { + // Partial failure: keep the cycle honest. Record the failure and do NOT + // stamp success, so the freshness surface reflects that some collections + // are behind and the next tick refetches them, rather than showing green. + const firstErr = result.failures[0]!.err; + getEvent()?.addWarning( + `oura: ${result.failures.length} collection(s) failed for ${userId}: ${result.failures + .map((f) => f.name) + .join(", ")}`, + ); + await recordSyncFailure({ + userId, + integration: "oura", + kind: classifyOuraFailure(firstErr), + message: `partial sync failure (${result.failures + .map((f) => f.name) + .join(", ")}): ${ + firstErr instanceof Error ? firstErr.message : String(firstErr) + }`, + errorCode: + firstErr instanceof OuraApiError && firstErr.httpStatus != null + ? String(firstErr.httpStatus) + : undefined, + }); + return imported; + } + await recordSyncSuccess(userId, "oura"); return imported; } diff --git a/src/lib/pwa/__tests__/query-client-options.test.ts b/src/lib/pwa/__tests__/query-client-options.test.ts new file mode 100644 index 000000000..d5172db6e --- /dev/null +++ b/src/lib/pwa/__tests__/query-client-options.test.ts @@ -0,0 +1,67 @@ +/** + * F-OFF-1 — offline mutations must FAIL fast (surfacing state) rather than + * pause forever and lose the write on reload. + * + * The library default network mode (`online`) pauses a mutation fired offline: + * it never rejects, `onError` never fires, and the paused mutation is dropped on + * reload — a silent health-data loss. This pins that the app config sets + * `networkMode: "always"` on mutations AND that, under that config, an offline + * mutation actually reaches the `error` state instead of `pending`/paused. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { + QueryClient, + MutationObserver, + onlineManager, +} from "@tanstack/react-query"; + +import { QUERY_CLIENT_DEFAULT_OPTIONS } from "../query-client-options"; + +afterEach(() => { + // Restore the online flag for any following test in the same worker. + onlineManager.setOnline(true); +}); + +describe("QUERY_CLIENT_DEFAULT_OPTIONS", () => { + it("runs mutations in 'always' network mode", () => { + expect(QUERY_CLIENT_DEFAULT_OPTIONS.mutations?.networkMode).toBe("always"); + }); + + it("an offline mutation errors instead of pausing (no silent write loss)", async () => { + const client = new QueryClient({ + defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS, + }); + + // Simulate the browser going offline. + onlineManager.setOnline(false); + + const observer = new MutationObserver(client, { + mutationFn: async () => { + // A real fetch would reject with a TypeError while offline. + throw new Error("Failed to fetch"); + }, + retry: 0, + }); + + let settled: "error" | "paused-timeout" = "paused-timeout"; + await Promise.race([ + observer + .mutate() + .then(() => {}) + .catch(() => { + settled = "error"; + }), + // If the mutation paused (the old default), it would never settle — cap + // the wait so the test fails as a timeout rather than hanging. + new Promise((resolve) => setTimeout(resolve, 200)), + ]); + + // In `always` mode the mutation ran and rejected — an honest error the UI + // can surface, not a paused mutation lost on reload. + expect(settled).toBe("error"); + expect(observer.getCurrentResult().status).toBe("error"); + expect(observer.getCurrentResult().isPaused).toBe(false); + + client.clear(); + }); +}); diff --git a/src/lib/pwa/query-client-options.ts b/src/lib/pwa/query-client-options.ts new file mode 100644 index 000000000..579ff9928 --- /dev/null +++ b/src/lib/pwa/query-client-options.ts @@ -0,0 +1,26 @@ +import type { DefaultOptions } from "@tanstack/react-query"; + +/** + * Shared TanStack Query default options for the app's single `QueryClient`. + * Extracted from `providers.tsx` so the offline-mutation contract (F-OFF-1) is + * unit-testable without importing the whole client-component tree. + * + * Mutations run in `always` network mode. The library default (`online`) PAUSES + * a mutation fired offline — `isPending` stays true forever, `onError` never + * fires, and the paused mutation is silently DROPPED on reload, so a health + * entry vanishes with no "not saved" signal. In `always` mode the mutation runs + * regardless, the offline `fetch` rejects immediately, and `onError` fires — an + * honest failure the form surfaces (and the global `OfflineMutationToaster` + * backstops), instead of a spinner-of-death plus silent write loss. + */ +export const QUERY_CLIENT_DEFAULT_OPTIONS: DefaultOptions = { + queries: { + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + retry: 1, + }, + mutations: { + networkMode: "always", + }, +}; diff --git a/src/lib/records/dto.ts b/src/lib/records/dto.ts index 6df06b9ae..5178914f2 100644 --- a/src/lib/records/dto.ts +++ b/src/lib/records/dto.ts @@ -8,6 +8,7 @@ * never 500s the whole page) — the IllnessEpisode DTO precedent. */ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { getEvent } from "@/lib/logging/context"; import type { Allergy, FamilyHistoryEntry } from "@/generated/prisma/client"; export interface AllergyDTO { @@ -39,7 +40,14 @@ function decryptOptional(buf: Uint8Array | null): string | null { if (!buf || buf.byteLength === 0) return null; try { return decryptFromBytes(buf); - } catch { + } catch (err) { + // Undecryptable payload (key gap / corruption): fail soft to null but log + // it (F-CRYPTO-2) so a systemic key gap surfaces instead of reading blank. + getEvent()?.addWarning( + `records field decrypt failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); return null; } } diff --git a/src/lib/withings/__tests__/sync.test.ts b/src/lib/withings/__tests__/sync.test.ts index b671761e5..a7027826b 100644 --- a/src/lib/withings/__tests__/sync.test.ts +++ b/src/lib/withings/__tests__/sync.test.ts @@ -1,5 +1,21 @@ import { afterEach, describe, expect, it } from "vitest"; -import { getWithingsWebhookCallbackUrl } from "../sync"; +import { + getWithingsWebhookCallbackUrl, + WITHINGS_INCREMENTAL_OVERLAP_MS, +} from "../sync"; + +/** + * F-SYNC-5 — the incremental overlap must stay comfortably wider than the old + * 60s so a backdated Withings reading landing just before the next cycle's + * `now()` is not missed (compounded by `lastSyncedAt` advancing on a healthy + * 200-with-0 cycle). The upserts are idempotent, so a wider overlap is safe. + */ +describe("WITHINGS_INCREMENTAL_OVERLAP_MS", () => { + it("is at least a few minutes (not the old 60s)", () => { + expect(WITHINGS_INCREMENTAL_OVERLAP_MS).toBeGreaterThanOrEqual(5 * 60_000); + expect(WITHINGS_INCREMENTAL_OVERLAP_MS).toBeGreaterThan(60_000); + }); +}); /** * v1.4.25 W17a — Withings webhook callback URL must put the shared diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 999d70d60..c887398f6 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -131,6 +131,20 @@ export async function getValidToken(userId: string): Promise<{ }; } +/** + * Incremental overlap window (ms). Withings backdates some readings (a scale + * step syncs to the cloud minutes after the reading's timestamp), and + * `lastSyncedAt` advances on every success — including a healthy 200-with-0 + * cycle (F-SYNC-1). A too-tight overlap let a reading that landed just before + * the next cycle's `now()` slip through the gap. 10 minutes is comfortably wider + * than the observed backdating skew while staying far short of a full re-scan; + * the upserts are idempotent, so a wider overlap only re-touches a handful of + * rows. (Still narrower than Google's 24h / Oura's 7-day because Withings is + * webhook-primary — this overlap is the poll-catch-up safety net, not the main + * path.) + */ +export const WITHINGS_INCREMENTAL_OVERLAP_MS = 10 * 60 * 1000; + /** * Sync measurements from Withings for a given user. * Fetches data since last sync (or last 30 days if first sync). @@ -172,7 +186,9 @@ export async function syncUserMeasurements( const startDate = opts.fullSync ? undefined : connection.lastSyncedAt - ? new Date(connection.lastSyncedAt.getTime() - 60 * 1000) // overlap 1 min to avoid gaps + ? new Date( + connection.lastSyncedAt.getTime() - WITHINGS_INCREMENTAL_OVERLAP_MS, + ) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back let measures: Awaited>;