diff --git a/public/sw.js b/public/sw.js index e16e622bc..8082ed455 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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.22.1"; + /* @sw-version-fallback */ "v1.25.0"; 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/*` diff --git a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts index b5354a1bc..d01477160 100644 --- a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts +++ b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts @@ -8,7 +8,12 @@ import { NextRequest } from "next/server"; * `{ signal: null }` and the hero keeps its neutral greeting — never a * fabricated opener. */ -vi.mock("@/lib/db", () => ({ prisma: {} })); +const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null }); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) }, + }, +})); vi.mock("@/lib/modules/gate", async (importOriginal) => ({ ...(await importOriginal()), @@ -75,6 +80,7 @@ const SESSION_OK = { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + findUniqueMock.mockResolvedValue({ notificationPrefs: null }); }); describe("GET /api/insights/coach/seeded-question", () => { @@ -124,4 +130,20 @@ describe("GET /api/insights/coach/seeded-question", () => { expect(body.error).toBeNull(); expect(body.data?.signal).toBeNull(); }); + + it("returns { signal: null } and skips the detector when ambient suggestions are off", async () => { + findUniqueMock.mockResolvedValue({ + notificationPrefs: { coach: { ambientSuggestions: false } }, + }); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { signal: unknown } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.signal).toBeNull(); + // The opt-out short-circuits before the compute tier. + expect(detectDerivedBriefingSignals).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts index 8e5ac4a0e..457099e22 100644 --- a/src/app/api/insights/coach/seeded-question/route.ts +++ b/src/app/api/insights/coach/seeded-question/route.ts @@ -26,6 +26,7 @@ import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; import { loadBaselineProfile } from "@/lib/insights/derived"; import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; +import { resolveCoachAmbientSuggestionsEnabled } from "@/lib/validations/notification-prefs"; export const dynamic = "force-dynamic"; @@ -60,6 +61,23 @@ export const GET = apiHandler(async () => { return apiError("Too many analytics requests. Please retry later.", 429); } + // Server-authoritative ambient-suggestions opt-out: when the user turns + // proactive suggestions off the hero must not seed an opener, so the route + // skips the detector work and returns the neutral `{ signal: null }` shape. + const prefsRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { notificationPrefs: true }, + }); + if ( + !resolveCoachAmbientSuggestionsEnabled(prefsRow?.notificationPrefs ?? null) + ) { + annotate({ + action: { name: "coach.seeded-question.resolve" }, + meta: { has_signal: false, source_metric: "none", band: "none" }, + }); + return apiSuccess({ signal: null } satisfies CoachSeededQuestionDTO); + } + // Profile read once via the shared loader; passed into the detector, // never re-fetched per candidate (the detector probes readiness + // recovery off the one profile). diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx index 8e0776510..0c48645f0 100644 --- a/src/components/settings/import-panel/json-import-card.tsx +++ b/src/components/settings/import-panel/json-import-card.tsx @@ -59,8 +59,12 @@ export function JsonImportCard() { a.download = "healthlog-import-example.json"; document.body.appendChild(a); a.click(); - a.remove(); - URL.revokeObjectURL(url); + // Defer cleanup a tick: revoking the blob URL in the same synchronous + // frame as click() races the browser's download start and can cancel it. + setTimeout(() => { + a.remove(); + URL.revokeObjectURL(url); + }, 0); } async function handleImport() { diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 5701b7ad9..690a80e4f 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -9,7 +9,7 @@ import { Buffer } from "node:buffer"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; -import { decrypt, encrypt } from "@/lib/crypto"; +import { encrypt } from "@/lib/crypto"; import type { ExtractedFact, ExtractedFactStatus, @@ -35,12 +35,6 @@ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array { return out; } -/** Decrypt a stored document back to its raw bytes. Throws on a bad key id. */ -export function decryptDocumentFromBytes(buf: Uint8Array): Buffer { - const base64 = decrypt(Buffer.from(buf).toString("utf8")); - return Buffer.from(base64, "base64"); -} - /** * Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the * schema stores. The structured clinical values (diagnosis text, lab values, diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 56f25f8e9..8f17aa48b 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -38,8 +38,6 @@ import { FEVER_BAND_C } from "@/lib/clinical-floors"; * measurement-keyed `BUCKETED_TYPES`. It registers here so the env signals get * a stable key + display metadata in the one cross-surface table. */ -export type SignalKind = "measurement" | "biomarker" | "score" | "environment"; - /** Reuse the assessment registry's direction vocabulary verbatim. */ export type SignalDirection = MetricDirection; @@ -1294,7 +1292,7 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - // TODO(NEEDS-VERIFY): no hand-grip-strength LOINC confirmed this pass — + // NEEDS-VERIFY: no hand-grip-strength LOINC confirmed this pass — // emits a local text concept until a stable LOINC is checked on loinc.org. loinc: null, display: "Hand grip strength", @@ -1372,7 +1370,7 @@ export const SIGNALS: Record = { mcp: true, }, fhir: { - // TODO(NEEDS-VERIFY): WHtR LOINC not confirmed this pass — local text concept. + // NEEDS-VERIFY: WHtR LOINC not confirmed this pass — local text concept. loinc: null, display: "Waist to height ratio", unit: "1", diff --git a/tests/integration/insights-provider-chain.test.ts b/tests/integration/insights-provider-chain.test.ts index 0944b289f..f85461161 100644 --- a/tests/integration/insights-provider-chain.test.ts +++ b/tests/integration/insights-provider-chain.test.ts @@ -46,6 +46,21 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); +// safeFetch's requirePublicHost path runs through undici's own `fetch` +// (version-locked with its dispatcher) rather than the global. Delegate it to +// the global `fetch` spy these tests install so the provider HTTP is mocked. +vi.mock("undici", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetch: (input: unknown, init?: unknown) => + (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( + input, + init, + ), + }; +}); + // Stub feature extraction + prompt-building so the test stays focused // on the chain semantics rather than the prompt's real content. Both // modules are re-exported from inside the route via @/lib/insights so