diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 29043ee61..6bfdd2954 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -147,6 +147,7 @@ export function buildMockSnapshot( briefing: null, briefingState: "preparing", briefingUpdatedAt: null, + briefingStale: false, generatedAt: now, }; } diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index cf35a6376..2a70b643e 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -410,6 +410,10 @@ export default function InsightsPage() { loading={advisor.isLoading} onRegenerate={advisor.regenerate} regenerating={advisor.isRegenerating} + // v1.15.20 — the read path reports a 422 (no provider configured) + // as its own outcome; swap the futile regenerate CTA for a quiet + // Settings → AI hint instead of an eternal "preparing" loop. + noProvider={advisor.readOutcome === "no-provider"} /> ) : null, vitals: , diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index 9e47069df..0f2b47a83 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -202,4 +202,33 @@ describe("", () => { const html = render(); expect(html).not.toContain('data-slot="daily-briefing-meta-slot"'); }); + + // v1.15.20 — no-provider state: the regenerate CTA would 422 forever, + // so the card points at Settings → AI instead. + it("renders the connect-provider hint with a settings link when noProvider", () => { + const html = render( + {}} noProvider />, + ); + expect(html).toContain('data-slot="daily-briefing-no-provider"'); + expect(html).toContain("No AI provider connected"); + expect(html).toMatch(/href="\/settings\/ai"/); + // The futile regenerate CTA must NOT render in this state. + expect(html).not.toContain('data-slot="daily-briefing-empty-cta"'); + }); + + it("renders the German connect-provider hint under the de locale", () => { + const html = render( + , + "de", + ); + expect(html).toContain("Kein KI-Anbieter verbunden"); + }); + + it("prefers the briefing content over the no-provider hint when both exist", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="daily-briefing-findings"'); + expect(html).not.toContain('data-slot="daily-briefing-no-provider"'); + }); }); diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 9394539db..46431096a 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -99,6 +99,12 @@ interface DailyBriefingProps { onRegenerate?: () => void; /** Disables the regenerate CTA while a generation is in flight. */ regenerating?: boolean; + /** + * v1.15.20 — no AI provider is configured anywhere, so generating is + * futile. The empty state swaps the regenerate CTA for a quiet hint + * linking to Settings → AI instead of an eternal "preparing" loop. + */ + noProvider?: boolean; /** * Optional slot for a meta control mounted in the card header — the * comparison toggle migrates here from the hero in commit 5. @@ -269,6 +275,7 @@ export function DailyBriefing({ loading = false, onRegenerate, regenerating = false, + noProvider = false, metaSlot, }: DailyBriefingProps) { const { t } = useTranslations(); @@ -343,6 +350,29 @@ export function DailyBriefing({

)} + ) : noProvider ? ( + // v1.15.20 — no provider configured anywhere: a regenerate CTA + // would 422 forever, so point at Settings → AI instead. + } + title={t("insights.dailyBriefing.noProviderTitle")} + description={t("insights.dailyBriefing.noProviderDescription")} + action={ + + } + /> ) : ( (regenerating); useEffect(() => { if (lastRegeneratingRef.current && !regenerating) { if (regenerateOutcome === "timeout") { toast.error(t("insights.regenerateError")); + } else if (regenerateOutcome === "rate-limited") { + toast.error(t("insights.regenerateRateLimited")); + } else if (regenerateOutcome === "empty") { + toast.error(t("insights.regenerateUnavailable")); } else if (regenerateOutcome !== "no-provider") { - // `"fresh"`, `"empty"`, or legacy `undefined`/null → success. + // `"fresh"` or legacy `undefined`/null → success. toast.success(t("insights.regenerateSuccess")); } } diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index ef159ec94..60734f65f 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -78,11 +78,17 @@ const FORCE_ADVISOR_TIMEOUT_MS = 45_000; * v1.15.18 — the outcome of a force regenerate, so the UI can be HONEST: * only a `fresh` outcome should toast "refreshed". A `timeout` (slow gen * the client gave up on) and a `no-provider` (422) are distinct failure - * modes the audit's Fix 2 + Fix 4 call out. + * modes. + * + * v1.15.20 — `rate-limited` (429) splits off from `empty`: the user's + * regenerate quota is exhausted, which deserves a "try again later" + * hint rather than the success toast the old lump produced. `empty` now + * means only the transient 503 surface (provider chain unavailable). */ export type AdvisorFetchOutcome = | "fresh" | "empty" + | "rate-limited" | "timeout" | "no-provider"; @@ -128,15 +134,19 @@ async function fetchAdvisor( clearTimeout(timeoutHandle); } if (!res.ok) { - // 422 (no provider configured) and 429 (rate-limited) are expected - // surfaces — return null so the consuming UI shows the empty / error - // state without the query slipping into an `isError` retry loop. 422 - // surfaces as `no-provider` so the regenerate path can distinguish a - // missing provider from a slow generation (audit Fix 4). + // 422 (no provider configured), 429 (rate-limited), and 503 + // (provider chain unavailable) are expected surfaces — return null + // so the consuming UI shows the empty / error state without the + // query slipping into an `isError` retry loop. Each gets its own + // outcome tag so the regenerate toast can be honest about WHY no + // fresh payload arrived. if (res.status === 422) { return { payload: null, outcome: "no-provider" }; } - if (res.status === 429 || res.status === 503) { + if (res.status === 429) { + return { payload: null, outcome: "rate-limited" }; + } + if (res.status === 503) { return { payload: null, outcome: "empty" }; } throw new Error(`HTTP ${res.status}`); @@ -191,6 +201,14 @@ export interface UseInsightsAdvisorResult { * (`"no-provider"`) never reads as "done". */ regenerateOutcome: AdvisorFetchOutcome | null; + /** + * v1.15.20 — the outcome of the last settled READ. Lets surfaces + * distinguish "no briefing yet, a generate could help" (`empty` / + * `timeout`) from "no provider configured, generating is futile" + * (`no-provider`) and render a connect-AI hint instead of a dead + * regenerate CTA. + */ + readOutcome: AdvisorFetchOutcome | null; } /** @@ -203,9 +221,10 @@ export function useInsightsAdvisorQuery( const queryClient = useQueryClient(); const query = useQuery({ queryKey: queryKeys.insightsAdvisor(), - // Map the tagged fetch result back onto the payload the cache stores — - // the read path only cares about the payload, not the outcome tag. - queryFn: async () => (await fetchAdvisor()).payload, + // v1.15.20 — the cache stores the full tagged result (payload + + // outcome) so the read path can surface WHY a payload is missing + // (`no-provider` → connect-AI hint instead of a dead regenerate CTA). + queryFn: () => fetchAdvisor(), enabled, // 24h cache window matches the server-side `insightsCachedAt` TTL. staleTime: 60 * 60 * 1000, @@ -214,11 +233,11 @@ export function useInsightsAdvisorQuery( const mutation = useMutation({ mutationFn: () => fetchAdvisor({ force: true }), - onSuccess: ({ payload }) => { - if (payload) { + onSuccess: (result) => { + if (result.payload) { // A genuinely fresh generation landed — write it into the shared // cache so the hero subtitle + briefing card repaint immediately. - queryClient.setQueryData(queryKeys.insightsAdvisor(), payload); + queryClient.setQueryData(queryKeys.insightsAdvisor(), result); } else { // No fresh payload (timeout / no-provider / transient). The server's // inline POST may STILL have written a fresh briefing after the @@ -245,7 +264,7 @@ export function useInsightsAdvisorQuery( const regenerate = useCallback(() => mutate(), [mutate]); return { - payload: query.data ?? null, + payload: query.data?.payload ?? null, isLoading: query.isLoading, isError: query.isError, error: (query.error as Error | null) ?? null, @@ -253,5 +272,6 @@ export function useInsightsAdvisorQuery( isRegenerating: mutation.isPending, regenerateError: (mutation.error as Error | null) ?? null, regenerateOutcome: mutation.data?.outcome ?? null, + readOutcome: query.data?.outcome ?? null, }; } diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 1bf6a8963..f6bd6973f 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -56,6 +56,7 @@ function buildUser(disableCoach: boolean): AuthUser { avatarUrl: null, glucoseUnit: null, unitPreference: "metric", + timeFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index e54166c25..7d30a5d59 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -755,7 +755,8 @@ async function buildCoachSnapshotImpl( : null; const intakeRowsPromise = wantsCompliance ? prisma.medicationIntakeEvent.findMany({ - where: { userId, scheduledFor: { gte: cutoff } }, + // Tombstoned intake rows must never reach the Coach snapshot. + where: { userId, deletedAt: null, scheduledFor: { gte: cutoff } }, orderBy: { scheduledFor: "asc" }, select: { scheduledFor: true, takenAt: true, skipped: true }, }) diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 90eda9d1d..049da4605 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -313,6 +313,116 @@ export async function resolveProviderChain( return resolved; } +/** + * Credential-presence subset of the `User` row that + * `userRowHasProviderCredential` evaluates. Mirrors the columns + * `resolveProviderChain` / `resolveProvider` read, but presence-only — + * no decrypt, no client construction, no token refresh. + */ +export interface ProviderCredentialRow { + aiProvider: string | null; + aiProviderChain: unknown; + aiAnthropicKeyEncrypted: string | null; + aiLocalKeyEncrypted: string | null; + aiOpenaiKeyEncrypted: string | null; + aiBaseUrl: string | null; + codexConnectionStatus: string | null; + codexAccessTokenEncrypted: string | null; + codexRefreshTokenEncrypted: string | null; +} + +/** + * Cheap, synchronous "is any provider configured?" check over an already + * loaded credential row. Mirrors the resolution semantics of + * `resolveProviderChain` + the legacy `resolveProvider` fallback (the + * exact pair `generateComprehensiveInsight` uses to decide + * `skipped: no-provider`), but evaluates credential PRESENCE only — + * it never decrypts a key, constructs a client, or refreshes a Codex + * token. A `true` here can still resolve to a dead provider at call + * time (revoked key, unreachable local host); callers use it to decide + * whether a generation is worth attempting at all, not as a liveness + * probe. + * + * `adminKeyConfigured` is the presence of `appSettings.adminAiKeyEncrypted` + * — passed in so batch callers can read it once for a whole cohort. + */ +export function userRowHasProviderCredential( + row: ProviderCredentialRow, + adminKeyConfigured: boolean, +): boolean { + const codexConnected = + row.codexConnectionStatus === "connected" && + !!row.codexAccessTokenEncrypted && + !!row.codexRefreshTokenEncrypted; + + const chain = parseProviderChain(row.aiProviderChain ?? null).filter( + (e) => e.enabled, + ); + for (const entry of chain) { + switch (entry.providerType) { + case "codex": + if (codexConnected) return true; + break; + case "openai": + if (row.aiOpenaiKeyEncrypted) return true; + break; + case "anthropic": + if (row.aiAnthropicKeyEncrypted) return true; + break; + case "local": + if (row.aiBaseUrl) return true; + break; + case "admin-openai": + if (adminKeyConfigured) return true; + break; + } + } + + // Legacy `resolveProvider()` fallback — only reached when the chain + // resolves empty. Mirrors buildUserProvider → codex → admin in order. + const choice = row.aiProvider?.toUpperCase(); + if (choice === "ANTHROPIC" && row.aiAnthropicKeyEncrypted) return true; + if (choice === "LOCAL" && row.aiBaseUrl) return true; + if (choice === "OPENAI" && row.aiOpenaiKeyEncrypted) return true; + if ((choice === "CHATGPT_OAUTH" || !choice) && codexConnected) return true; + return adminKeyConfigured; +} + +/** + * Async single-user variant of `userRowHasProviderCredential`: two + * narrow presence reads (user credential columns + the admin key flag), + * no decrypt, no network. Used by read paths that only need to know + * whether a generation could ever produce provider-backed text (e.g. + * the dashboard snapshot's `briefingState: "no-provider"`). + */ +export async function hasAnyConfiguredProvider( + userId: string, +): Promise { + const userRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { + aiProvider: true, + aiProviderChain: true, + aiAnthropicKeyEncrypted: true, + aiLocalKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, + aiBaseUrl: true, + codexConnectionStatus: true, + codexAccessTokenEncrypted: true, + codexRefreshTokenEncrypted: true, + }, + }); + if (!userRow) return false; + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { adminAiKeyEncrypted: true }, + }); + return userRowHasProviderCredential( + userRow, + !!settings?.adminAiKeyEncrypted, + ); +} + /** * Materialise a single chain entry. Returns null when the user lacks * the matching credential — the chain runner skips null entries. diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index b8fa2643f..991e3dcc8 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -6,7 +6,8 @@ * - every above-the-fold tile field is present in the envelope; * - the two-phase contract: `extras` is null on a rollup-coverage * miss, populated when warm; - * - the briefingState matrix (ready / preparing / disabled); + * - the briefingState matrix (ready / preparing / disabled / + * no-provider) plus the stale-briefing delivery contract; * - NO LLM client is reachable from the builder (the provider chain * is never imported by `snapshot.ts` — asserted structurally). */ @@ -19,6 +20,7 @@ const readMoodDayRollups = vi.fn(); const ensureUserMoodRollupsFresh = vi.fn(); const computeBpInTargetFastPath = vi.fn(); const getAssistantFlags = vi.fn(); +const hasAnyConfiguredProvider = vi.fn(); vi.mock("@/lib/analytics/summaries-slice", () => ({ computeSummariesSlice: (...a: unknown[]) => computeSummariesSlice(...a), @@ -39,6 +41,11 @@ vi.mock("@/lib/analytics/bp-in-target-fast-path", () => ({ vi.mock("@/lib/feature-flags", () => ({ getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), })); +// The builder imports only the credential-PRESENCE check; mock it so the +// test never touches the real provider module (prisma / crypto imports). +vi.mock("@/lib/ai/provider", () => ({ + hasAnyConfiguredProvider: (...a: unknown[]) => hasAnyConfiguredProvider(...a), +})); import { buildDashboardSnapshot, @@ -104,6 +111,7 @@ beforeEach(() => { }); fakePrisma.measurement.findMany.mockResolvedValue([]); fakePrisma.moodEntry.findMany.mockResolvedValue([]); + hasAnyConfiguredProvider.mockResolvedValue(true); }); describe("buildDashboardSnapshot — envelope shape", () => { @@ -211,7 +219,41 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { expect(snap.briefing!.paragraph).toContain("grünen"); }); - it("preparing — cache older than 24h", async () => { + it("preparing — cache older than 24h still DELIVERS the stale briefing with briefingStale: true", async () => { + const cachedAt = new Date(Date.now() - 25 * 60 * 60 * 1000); + const snap = await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: cachedAt, + insightsCachedText: JSON.stringify({ + dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] }, + }), + }), + ); + expect(snap.briefingState).toBe("preparing"); + // v1.15.20 — the last good briefing rides along instead of a blank + // tile; the stale flag + timestamp carry the honesty. + expect(snap.briefing).not.toBeNull(); + expect(snap.briefingStale).toBe(true); + expect(snap.briefingUpdatedAt).toBe(cachedAt.toISOString()); + }); + + it("preparing — never generated (null cache)", async () => { + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + expect(snap.briefingState).toBe("preparing"); + expect(snap.briefing).toBeNull(); + expect(snap.briefingStale).toBe(false); + }); + + it("no-provider — stale cache and no provider configured anywhere", async () => { + hasAnyConfiguredProvider.mockResolvedValue(false); + const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); + expect(snap.briefingState).toBe("no-provider"); + expect(snap.briefing).toBeNull(); + }); + + it("no-provider — still delivers a stale briefing when one exists", async () => { + hasAnyConfiguredProvider.mockResolvedValue(false); const snap = await buildDashboardSnapshot( fakePrisma, baseUser({ @@ -221,14 +263,22 @@ describe("buildDashboardSnapshot — briefingState matrix", () => { }), }), ); - expect(snap.briefingState).toBe("preparing"); - expect(snap.briefing).toBeNull(); + expect(snap.briefingState).toBe("no-provider"); + expect(snap.briefing).not.toBeNull(); + expect(snap.briefingStale).toBe(true); }); - it("preparing — never generated (null cache)", async () => { - const snap = await buildDashboardSnapshot(fakePrisma, baseUser()); - expect(snap.briefingState).toBe("preparing"); - expect(snap.briefing).toBeNull(); + it("ready — the provider presence probe never runs on a fresh cache", async () => { + await buildDashboardSnapshot( + fakePrisma, + baseUser({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ + dailyBriefing: { greeting: "x", paragraph: "y", keyFindings: [] }, + }), + }), + ); + expect(hasAnyConfiguredProvider).not.toHaveBeenCalled(); }); it("disabled — coach surface off (flag)", async () => { @@ -453,9 +503,10 @@ describe("buildDashboardSnapshot — additive proof", () => { for (const key of legacyKeys) { expect(snap).toHaveProperty(key); } - // The two new additive blocks sit alongside, not in place of. + // The new additive blocks sit alongside, not in place of. expect(snap).toHaveProperty("metricStates"); expect(snap).toHaveProperty("layoutCatalogue"); + expect(snap).toHaveProperty("briefingStale"); // The web-consumed `layout` block is the resolved per-user layout — // NOT replaced by the 35-id catalogue. diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 71ec5be29..945ee4f14 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -28,8 +28,11 @@ * * No LLM is reachable from this builder. The briefing is lifted * read-only from `User.insightsCachedText`; a cache miss / stale row - * yields `briefingState: "preparing"` and the `insight-pregenerate` - * cron refills it. The builder NEVER POSTs `/api/insights/generate`. + * yields `briefingState: "preparing"` (with the last good briefing + * delivered under `briefingStale: true` when one exists) and the + * `insight-pregenerate` cron refills it — or `"no-provider"` when no + * AI provider is configured anywhere, so clients can stop waiting. + * The builder NEVER POSTs `/api/insights/generate`. */ import type { PrismaClient, @@ -61,6 +64,7 @@ import { } from "@/lib/dashboard-layout"; import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema"; import { getAssistantFlags } from "@/lib/feature-flags"; +import { hasAnyConfiguredProvider } from "@/lib/ai/provider"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; /** Briefing freshness window — mirrors the 24 h TTL on the advisor cache. */ @@ -137,7 +141,14 @@ const METRIC_KIND_RAW_BY_TYPE: Partial> = { TIME_IN_DAYLIGHT: "timeInDaylight", }; -export type BriefingState = "ready" | "preparing" | "disabled"; +/** + * v1.15.20 — `"no-provider"` joins the tri-state: the cache is stale or + * missing AND no AI provider is configured anywhere (user chain, legacy + * selection, Codex link, operator key), so no warm pass will ever fill + * it. Clients render a "connect a provider" hint instead of an eternal + * "preparing" spinner. Existing states keep their exact semantics. + */ +export type BriefingState = "ready" | "preparing" | "disabled" | "no-provider"; export interface DashboardSnapshotUser { username: string; @@ -233,6 +244,15 @@ export interface DashboardSnapshot { briefing: DailyBriefing | null; briefingState: BriefingState; briefingUpdatedAt: string | null; + /** + * v1.15.20 — additive honesty flag. `true` when `briefing` carries the + * LAST GOOD (expired-TTL) briefing while a refresh is pending + * (`briefingState: "preparing"`) or will never come + * (`"no-provider"`). Clients show the stale content with its + * `briefingUpdatedAt` timestamp instead of a blank tile — the same + * stale-while-revalidate honesty the insights page uses. + */ + briefingStale: boolean; generatedAt: string; } @@ -430,24 +450,53 @@ async function buildExtras( }; } +/** Parse + validate the cached `dailyBriefing` block; null when unusable. */ +function parseCachedBriefing( + cachedText: string | null, +): DailyBriefing | null { + if (!cachedText) return null; + try { + const parsed = JSON.parse(cachedText) as Record; + const candidate = parsed?.dailyBriefing; + if (candidate == null) return null; + const validated = dailyBriefingSchema.safeParse(candidate); + return validated.success ? validated.data : null; + } catch { + return null; + } +} + /** * Read-only briefing lift. Parses `User.insightsCachedText`, validates - * the `dailyBriefing` block, and reports a tri-state. NEVER calls the - * provider chain. + * the `dailyBriefing` block, and reports a four-state. NEVER calls the + * provider chain — `hasProvider` is a credential-PRESENCE thunk (two + * narrow reads, no decrypt, no network) evaluated only on the + * stale/missing path, so the common warm-cache snapshot pays nothing. + * + * v1.15.20 — two honesty upgrades over the old tri-state: + * - a stale-but-parseable briefing is DELIVERED with + * `briefingStale: true` (plus its timestamp) instead of `null`, so + * the tile can show yesterday's content while the warm pass runs; + * - when no provider is configured anywhere the state is + * `"no-provider"` instead of an eternal `"preparing"`, so clients + * can point at Settings → AI rather than spin forever. */ -function liftBriefing( +async function liftBriefing( user: SnapshotUserInput, coachEnabled: boolean, -): { + hasProvider: () => Promise, +): Promise<{ briefing: DailyBriefing | null; briefingState: BriefingState; briefingUpdatedAt: string | null; -} { + briefingStale: boolean; +}> { if (!coachEnabled || user.disableCoach) { return { briefing: null, briefingState: "disabled", briefingUpdatedAt: null, + briefingStale: false, }; } @@ -455,47 +504,30 @@ function liftBriefing( const updatedAt = cachedAt?.toISOString() ?? null; const stale = !cachedAt || Date.now() - cachedAt.getTime() >= BRIEFING_TTL_MS; - if (stale || !user.insightsCachedText) { + const cachedBriefing = parseCachedBriefing(user.insightsCachedText); + + if (!stale && cachedBriefing) { return { - briefing: null, - briefingState: "preparing", + briefing: cachedBriefing, + briefingState: "ready", briefingUpdatedAt: updatedAt, + briefingStale: false, }; } - try { - const parsed = JSON.parse(user.insightsCachedText) as Record< - string, - unknown - >; - const candidate = parsed?.dailyBriefing; - if (candidate == null) { - return { - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: updatedAt, - }; - } - const validated = dailyBriefingSchema.safeParse(candidate); - if (!validated.success) { - return { - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: updatedAt, - }; - } - return { - briefing: validated.data, - briefingState: "ready", - briefingUpdatedAt: updatedAt, - }; - } catch { - return { - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: updatedAt, - }; - } + // Stale or unusable cache — distinguish "a warm pass will fill this" + // from "nothing ever will" so the client can stop waiting honestly. + const briefingState: BriefingState = (await hasProvider()) + ? "preparing" + : "no-provider"; + return { + // Serve the last good briefing (when one parses) instead of a blank + // tile; `briefingStale: true` + the timestamp carry the honesty. + briefing: cachedBriefing, + briefingState, + briefingUpdatedAt: updatedAt, + briefingStale: cachedBriefing !== null, + }; } /** @@ -592,6 +624,12 @@ export async function buildDashboardSnapshot( options: { /** Optional per-sub-query timing sink (route surfaces it under `meta`). */ time?: (label: string, fn: () => Promise) => Promise; + /** + * Provider credential-presence probe for the briefing lift — + * injectable for tests. Defaults to the cheap presence check in + * `@/lib/ai/provider` (no decrypt, no network). + */ + hasProvider?: () => Promise; } = {}, ): Promise { const userTz = user.timezone ?? DEFAULT_TIMEZONE; @@ -620,7 +658,11 @@ export async function buildDashboardSnapshot( ]); const layout = resolveDashboardLayout(user.dashboardWidgetsJson); - const briefing = liftBriefing(user, flags.briefing); + const briefing = await liftBriefing( + user, + flags.briefing, + options.hasProvider ?? (() => hasAnyConfiguredProvider(user.id)), + ); const gender = user.gender === "MALE" || user.gender === "FEMALE" ? user.gender : null; @@ -651,6 +693,7 @@ export async function buildDashboardSnapshot( briefing: briefing.briefing, briefingState: briefing.briefingState, briefingUpdatedAt: briefing.briefingUpdatedAt, + briefingStale: briefing.briefingStale, generatedAt: now.toISOString(), }; } diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx index b09bad953..8dca9415a 100644 --- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx @@ -87,6 +87,7 @@ function buildUser(disableCoach: boolean): AuthUser { avatarUrl: null, glucoseUnit: null, unitPreference: "metric", + timeFormat: "AUTO", disableCoach, fullName: null, insurerName: null, diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts new file mode 100644 index 000000000..8e2778214 --- /dev/null +++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts @@ -0,0 +1,50 @@ +/** + * v1.15.20 — soft-deleted rows must never reach an AI prompt snapshot. + * + * The user-facing DELETE flips `deletedAt` instead of removing the row, + * so every prompt-feeding read has to filter the tombstones explicitly. + * Three status generators (general / weight / bmi) and the Coach + * snapshot's intake read shipped without the filter; this guard pins + * the `deletedAt: null` predicate at each of those call sites so a + * future query rewrite cannot silently drop it (same source-guard + * pattern the queue-registration tests use). + */ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +function read(rel: string): string { + return fs.readFileSync(path.resolve(__dirname, rel), "utf8"); +} + +describe("prompt reads exclude tombstoned rows", () => { + it("general-status filters its measurement read", () => { + const src = read("../general-status.ts"); + expect(src).toMatch( + /measurement\s*\n?\s*\.findMany\(\{\s*where:\s*\{[\s\S]{0,300}?\bdeletedAt:\s*null/, + ); + }); + + it("weight-status filters its measurement read", () => { + const src = read("../weight-status.ts"); + expect(src).toMatch( + /measurement\s*\n?\s*\.findMany\(\{\s*where:\s*\{[\s\S]{0,300}?\bdeletedAt:\s*null/, + ); + }); + + it("bmi-status filters its measurement read", () => { + const src = read("../bmi-status.ts"); + expect(src).toMatch( + /measurement\s*\n?\s*\.findMany\(\{\s*where:\s*\{[\s\S]{0,300}?\bdeletedAt:\s*null/, + ); + }); + + it("the Coach snapshot filters its intake read", () => { + const src = read("../../ai/coach/snapshot.ts"); + const intakeRead = src.match( + /medicationIntakeEvent\.findMany\(\{[\s\S]{0,200}?where:\s*\{[^}]*\}/, + ); + expect(intakeRead).not.toBeNull(); + expect(intakeRead![0]).toContain("deletedAt: null"); + }); +}); diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index d30ee3398..f30555b77 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -128,6 +128,8 @@ export async function generateBmiStatusForUser( where: { userId, type: "WEIGHT", + // Soft-deleted rows must never reach the prompt snapshot. + deletedAt: null, }, orderBy: { measuredAt: "desc" }, take: 365, diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 5e429520b..c8174869a 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -10,6 +10,7 @@ import { buildComplianceMedicationContext, calculateCompliance, lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { @@ -911,7 +912,9 @@ export async function extractFeatures( // Medications const medications = await prisma.medication.findMany({ where: { userId, active: true }, - include: { schedules: true }, + // v1.15.20 — schedules through the shared compliance select so the + // configured per-dose windows reach this surface like every other. + include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT } }, }); if (medications.length > 0) { diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 98d06da5c..a0c325324 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -132,6 +132,8 @@ export async function generateGeneralStatusForUser( type: { in: [...MEASUREMENT_TYPES], }, + // Soft-deleted rows must never reach the prompt snapshot. + deletedAt: null, }, orderBy: { measuredAt: "desc" }, take: 5000, diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index f10bbdd25..04599c6d1 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -32,11 +32,16 @@ export function normalizeSummaryText(value: string): string { return stripChartTokens(value).replace(/\s+/g, " ").trim(); } -/** Narrow an arbitrary locale string to the two the prompts support. */ +/** + * Narrow an arbitrary locale string to the two the prompts support. + * Non-German locales (fr/es/it/pl, unknown values) resolve to ENGLISH — + * the same routing the no-key fallbacks use — so a French UI never + * receives German AI prose just because the prompts ship DE + EN only. + */ export function normalizeLocale( value: string | null | undefined, ): SupportedLocale { - return value === "en" ? "en" : "de"; + return value === "de" ? "de" : "en"; } export interface SeriesSummary { diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 59b69544a..14c9faca4 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -172,6 +172,8 @@ export async function generateWeightStatusForUser( type: { in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], }, + // Soft-deleted rows must never reach the prompt snapshot. + deletedAt: null, }, take: 1095, orderBy: { measuredAt: "desc" }, diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index ee4cdca32..3360a6789 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -212,9 +212,10 @@ describe("runInsightPregenerate — outcome tally", () => { failed: 1, budgetBlocked: 0, }); - // Locale defaulting: null → "de". + // Locale defaulting: v1.15.20 — non-German (incl. null) → "en", + // matching the no-key fallback routing. expect(generate).toHaveBeenLastCalledWith("d", { - locale: "de", + locale: "en", force: true, }); }); @@ -662,9 +663,9 @@ describe("queue registration", () => { expect(match![1]).toMatch(/\bINSIGHT_PREGENERATE_QUEUE\b/); }); - it("schedules the cron in the schedules table", () => { + it("schedules the cron in the schedules table (with retry policy)", () => { expect(workerSrc).toMatch( - /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*\]/, + /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*insightRetryOptions\s*\]/, ); }); diff --git a/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts b/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts index 3a2d99c0c..05ccc4bb3 100644 --- a/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts +++ b/src/lib/jobs/__tests__/intake-slot-dedup-queue.test.ts @@ -62,8 +62,11 @@ describe("reminder-worker — intake-slot-dedup wiring", () => { }); it("registers the cron in the schedules table", () => { + // v1.15.20 — the schedules tuple grew an optional send-options slot + // (retry policy for the insight queues); match on the assignment, not + // the exact tuple type. const schedulesMatch = source.match( - /const schedules:\s*\[string, string\]\[\]\s*=\s*\[([\s\S]*?)\n \];/, + /const schedules:[^=]*=\s*\[([\s\S]*?)\n \];/, ); expect(schedulesMatch).not.toBeNull(); expect(schedulesMatch![1]).toMatch( diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index 52eb46354..6f507f8fa 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -25,9 +25,22 @@ import { PERIOD_NARRATIVE_CRON, } from "../period-narrative-warm"; -function makePrisma(users: Array<{ id: string; locale: string | null }>) { +function makePrisma( + users: Array<{ id: string; locale: string | null }>, + narrativeWrites: Array<{ userId: string; updatedAt: Date }> = [], +) { const findMany = vi.fn().mockResolvedValue(users); - return { prisma: { user: { findMany } }, findMany }; + const groupBy = vi.fn().mockResolvedValue( + narrativeWrites.map((row) => ({ + userId: row.userId, + _max: { updatedAt: row.updatedAt }, + })), + ); + return { + prisma: { user: { findMany }, insightNarrative: { groupBy } }, + findMany, + groupBy, + }; } // A Monday that is also the 1st of the month — both periods warm. @@ -61,13 +74,37 @@ describe("periodsForDay — boundary gate", () => { }); describe("findNarrativeCandidates", () => { - it("filters coach-enabled users, capped", async () => { - const { prisma, findMany } = makePrisma([{ id: "u1", locale: "de" }]); + it("filters coach-enabled users", async () => { + const { prisma, findMany, groupBy } = makePrisma([ + { id: "u1", locale: "de" }, + ]); // eslint-disable-next-line @typescript-eslint/no-explicit-any - await findNarrativeCandidates(prisma as any, 50); + const result = await findNarrativeCandidates(prisma as any, 50); const arg = findMany.mock.calls[0][0]; expect(arg.where.disableCoach).toBe(false); - expect(arg.take).toBe(50); + expect(result).toEqual([{ id: "u1", locale: "de" }]); + // Below the cap the ordering aggregate never runs. + expect(groupBy).not.toHaveBeenCalled(); + }); + + it("serves never-warmed and oldest-narrative users first when the cap bites", async () => { + const { prisma, groupBy } = makePrisma( + [ + { id: "fresh", locale: "de" }, + { id: "stale", locale: "de" }, + { id: "never", locale: "de" }, + ], + [ + { userId: "fresh", updatedAt: new Date("2026-06-09T05:00:00Z") }, + { userId: "stale", updatedAt: new Date("2026-05-01T05:00:00Z") }, + ], + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findNarrativeCandidates(prisma as any, 2); + expect(groupBy).toHaveBeenCalledTimes(1); + // never-warmed first (no narrative row), then the staleest write; + // the freshest user falls past the cap. + expect(result.map((c) => c.id)).toEqual(["never", "stale"]); }); }); @@ -150,9 +187,9 @@ describe("queue registration", () => { expect(match![1]).toMatch(/\bPERIOD_NARRATIVE_QUEUE\b/); }); - it("schedules the cron in the schedules table", () => { + it("schedules the cron in the schedules table (with retry policy)", () => { expect(workerSrc).toMatch( - /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*\]/, + /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*insightRetryOptions\s*\]/, ); }); diff --git a/src/lib/jobs/__tests__/report-worker-error.test.ts b/src/lib/jobs/__tests__/report-worker-error.test.ts new file mode 100644 index 000000000..4d887754a --- /dev/null +++ b/src/lib/jobs/__tests__/report-worker-error.test.ts @@ -0,0 +1,81 @@ +/** + * v1.15.20 — unit tests for the central worker-error reporter. + * + * Pins the contract: stderr + GlitchTip with the api-handler redaction, + * disabled-settings short-circuit, and the never-throws guarantee. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const getGlitchtipSettings = vi.fn(); +const sendGlitchtipEvent = vi.fn(); + +vi.mock("@/lib/monitoring-settings", () => ({ + getGlitchtipSettings: (...a: unknown[]) => getGlitchtipSettings(...a), +})); +vi.mock("@/lib/monitoring/glitchtip", () => ({ + sendGlitchtipEvent: (...a: unknown[]) => sendGlitchtipEvent(...a), +})); + +import { reportWorkerError } from "../report-worker-error"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + getGlitchtipSettings.mockResolvedValue({ + glitchtipEnabled: true, + glitchtipDsn: "https://key@glitchtip.example/1", + glitchtipEnvironment: "test", + }); + sendGlitchtipEvent.mockResolvedValue({ ok: true }); +}); + +describe("reportWorkerError", () => { + it("forwards queue-tagged, redacted errors to GlitchTip", async () => { + await reportWorkerError( + "insight-pregenerate", + new Error("provider call failed"), + { mode: "scheduled" }, + ); + expect(sendGlitchtipEvent).toHaveBeenCalledTimes(1); + const { dsn, input } = sendGlitchtipEvent.mock.calls[0][0]; + expect(dsn).toBe("https://key@glitchtip.example/1"); + expect(input.message).toBe( + "[insight-pregenerate] provider call failed (mode=scheduled)", + ); + expect(input.level).toBe("error"); + expect(input.sourceTag).toBe("healthlog-worker"); + expect(input.environment).toBe("test"); + }); + + it("redacts secret-shaped material from the message", async () => { + await reportWorkerError( + "insight-status-generate", + new Error("upstream rejected Bearer sk-abcdef1234567890abcdef"), + ); + const { input } = sendGlitchtipEvent.mock.calls[0][0]; + expect(input.message).not.toContain("sk-abcdef1234567890abcdef"); + }); + + it("still writes stderr when GlitchTip is disabled", async () => { + getGlitchtipSettings.mockResolvedValue({ + glitchtipEnabled: false, + glitchtipDsn: null, + }); + await reportWorkerError("drain-cumulative", new Error("boom")); + expect(sendGlitchtipEvent).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalled(); + }); + + it("never throws — a sink failure cannot mask the handler error", async () => { + sendGlitchtipEvent.mockRejectedValue(new Error("glitchtip down")); + await expect( + reportWorkerError("period-narrative-warm", new Error("boom")), + ).resolves.toBeUndefined(); + }); + + it("wraps non-Error inputs", async () => { + await reportWorkerError("mean-consolidation", "string failure"); + const { input } = sendGlitchtipEvent.mock.calls[0][0]; + expect(input.message).toBe("[mean-consolidation] string failure"); + }); +}); diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts new file mode 100644 index 000000000..b694cfc94 --- /dev/null +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -0,0 +1,212 @@ +/** + * v1.15.20 — unit tests for the shared 02:xx status-cron discovery. + * + * Pins the three gates the discovery applies: + * 1. the operator assistant kill-switch (`insightStatus` flag), + * 2. the per-user `disableCoach: false` filter, + * 3. the pregenerate-candidate skip (configured provider + stale + * comprehensive cache → the 04:30 pass owns the user; the 02:xx + * crons keep fresh-cache and provider-less accounts), + * plus the mood-status queue registration in the worker source (the + * v1.4.37 dead-queue class guard). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const getAssistantFlags = vi.fn(); +vi.mock("@/lib/feature-flags", () => ({ + getAssistantFlags: (...a: unknown[]) => getAssistantFlags(...a), +})); + +import { findStatusCronCandidates } from "../status-cron-candidates"; +import { PREGENERATE_STALE_MS } from "../insight-pregenerate"; + +const NOW = new Date("2026-06-10T02:00:00.000Z"); +const STALE_AT = new Date(NOW.getTime() - PREGENERATE_STALE_MS - 60_000); +const FRESH_AT = new Date(NOW.getTime() - 60_000); + +interface FakeUserRow { + id: string; + locale: string | null; + insightsCachedAt: Date | null; + aiProvider: string | null; + aiProviderChain: unknown; + aiAnthropicKeyEncrypted: string | null; + aiLocalKeyEncrypted: string | null; + aiOpenaiKeyEncrypted: string | null; + aiBaseUrl: string | null; + codexConnectionStatus: string | null; + codexAccessTokenEncrypted: string | null; + codexRefreshTokenEncrypted: string | null; +} + +function userRow(overrides: Partial = {}): FakeUserRow { + return { + id: "u1", + locale: "de", + insightsCachedAt: null, + aiProvider: null, + aiProviderChain: null, + aiAnthropicKeyEncrypted: null, + aiLocalKeyEncrypted: null, + aiOpenaiKeyEncrypted: null, + aiBaseUrl: null, + codexConnectionStatus: null, + codexAccessTokenEncrypted: null, + codexRefreshTokenEncrypted: null, + ...overrides, + }; +} + +function makePrisma( + users: FakeUserRow[], + adminAiKeyEncrypted: string | null = null, +) { + const findMany = vi.fn().mockResolvedValue(users); + const findUnique = vi.fn().mockResolvedValue({ adminAiKeyEncrypted }); + return { + prisma: { + user: { findMany }, + appSettings: { findUnique }, + }, + findMany, + findUnique, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + getAssistantFlags.mockResolvedValue({ + enabled: true, + coach: true, + briefing: true, + insightStatus: true, + }); +}); + +describe("findStatusCronCandidates — gates", () => { + it("returns nothing when the insightStatus surface is disabled (operator kill-switch)", async () => { + getAssistantFlags.mockResolvedValue({ + enabled: false, + coach: false, + briefing: false, + insightStatus: false, + }); + const { prisma, findMany } = makePrisma([userRow()]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([]); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("filters discovery to disableCoach: false", async () => { + const { prisma, findMany } = makePrisma([userRow()]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await findStatusCronCandidates(prisma as any, NOW); + expect(findMany.mock.calls[0][0].where).toEqual({ disableCoach: false }); + }); + + it("skips a pregenerate candidate (configured provider + stale cache)", async () => { + const { prisma } = makePrisma([ + userRow({ + id: "stale-with-provider", + insightsCachedAt: STALE_AT, + aiAnthropicKeyEncrypted: "enc", + }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([]); + }); + + it("keeps a provider user whose comprehensive cache is still fresh", async () => { + const { prisma } = makePrisma([ + userRow({ + id: "fresh-with-provider", + insightsCachedAt: FRESH_AT, + aiAnthropicKeyEncrypted: "enc", + }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([{ id: "fresh-with-provider", locale: "de" }]); + }); + + it("keeps a provider-less user even with a stale cache (no pregenerate claim)", async () => { + const { prisma } = makePrisma([ + userRow({ id: "no-provider", insightsCachedAt: STALE_AT }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([{ id: "no-provider", locale: "de" }]); + }); + + it("counts the operator's shared admin key as a configured provider", async () => { + const { prisma } = makePrisma( + [userRow({ id: "admin-covered", insightsCachedAt: STALE_AT })], + "admin-enc", + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([]); + }); + + it("keeps every user when the briefing surface is off (the 04:30 pass no-ops)", async () => { + getAssistantFlags.mockResolvedValue({ + enabled: true, + coach: true, + briefing: false, + insightStatus: true, + }); + const { prisma, findUnique } = makePrisma([ + userRow({ + id: "stale-with-provider", + insightsCachedAt: STALE_AT, + aiAnthropicKeyEncrypted: "enc", + }), + ]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await findStatusCronCandidates(prisma as any, NOW); + expect(result).toEqual([{ id: "stale-with-provider", locale: "de" }]); + // No admin-key read needed when the skip never applies. + expect(findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("mood-status queue registration (dead-queue guard)", () => { + const workerSrc = fs.readFileSync( + path.resolve(__dirname, "../reminder-worker.ts"), + "utf8", + ); + + it("registers the queue in the allQueues createQueue loop", () => { + const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(match).not.toBeNull(); + expect(match![1]).toMatch(/\bMOOD_STATUS_QUEUE\b/); + }); + + it("schedules the 02:30 cron with the insight retry policy", () => { + expect(workerSrc).toMatch( + /\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*insightRetryOptions\s*\]/, + ); + expect(workerSrc).toMatch( + /MOOD_STATUS_CRON\s*=\s*"30 2 \* \* \*"/, + ); + }); + + it("registers a boss.work handler for the queue", () => { + expect(workerSrc).toMatch(/boss\.work[\s\S]{0,120}MOOD_STATUS_QUEUE/); + }); + + it("drives all seven status crons through the shared discovery", () => { + expect(workerSrc).toMatch(/findStatusCronCandidates\(prisma\)/); + // The old iterate-every-user discovery must be gone from the status + // handlers (the WHOOP/data-backup cohort reads keep their own scans). + const statusBlock = workerSrc.slice( + workerSrc.indexOf("async function runStatusCronGenerate"), + workerSrc.indexOf("async function handleMoodLogSync"), + ); + expect(statusBlock).not.toMatch(/prisma\.user\.findMany/); + }); +}); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index 564b0ec09..2d4e5d1a8 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -67,6 +67,11 @@ export async function enqueueForceWarm(payload: { // De-dupe within a 2-minute window so a double tap or an // auto-trigger racing a manual tap enqueues one warm, not two. singletonSeconds: 120, + // Transient provider / pool failures retry with backoff instead + // of leaving every cache cold until the nightly cron. + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, }, ); annotate({ diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 54e4d137c..39e08f824 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -337,8 +337,13 @@ export async function findPregenerateCandidates( }); } +/** + * Non-German locales resolve to ENGLISH (matching + * `status-shared.normalizeLocale` and the no-key fallback routing) so a + * fr/es/it/pl account gets English AI prose, not German. + */ function normalizeLocale(value: string | null): "de" | "en" { - return value === "en" ? "en" : "de"; + return value === "de" ? "de" : "en"; } /** diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 42ac9c741..0811e46d2 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -107,6 +107,11 @@ export async function enqueueStatusGeneration( // De-dupe within a short window so a polling client doesn't enqueue // a fresh job on every tick while a generation is already running. singletonSeconds: 120, + // Transient provider / pool failures retry with backoff instead of + // leaving the card on "preparing" until the next nightly cron. + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, }); annotate({ action: { name: "insights.status.generate.enqueued" }, diff --git a/src/lib/jobs/period-narrative-shared.ts b/src/lib/jobs/period-narrative-shared.ts index 47ffc0e44..c6829e148 100644 --- a/src/lib/jobs/period-narrative-shared.ts +++ b/src/lib/jobs/period-narrative-shared.ts @@ -62,6 +62,11 @@ export async function enqueueNarrativeWarm(payload: { { singletonKey: `warm:${payload.userId}:${payload.period}:${payload.locale}`, singletonSeconds: 120, + // Transient provider / pool failures retry with backoff instead + // of leaving the narrative cold until the next boundary night. + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, }, ); annotate({ diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 067f2d266..cbcaa48da 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -96,20 +96,47 @@ interface NarrativeCandidate { * a provider / enough history is confirmed inside the generator (skipped / * insufficient), so a provider-less or sparse account costs at most one cheap * chain-resolve and no LLM call. + * + * Ordering needs a second read: `InsightNarrative` keeps one row per + * (user, period, locale), so the user's most recent narrative write is a + * per-user `MAX(updated_at)` — never-warmed users (no rows at all) sort + * first, then the staleest. The aggregate only runs when the cohort + * actually exceeds the cap; below it every candidate is processed anyway + * and the order is irrelevant. */ export async function findNarrativeCandidates( prisma: PrismaClient, cap: number, ): Promise { - return prisma.user.findMany({ + const users: NarrativeCandidate[] = await prisma.user.findMany({ where: { disableCoach: false }, - take: cap, select: { id: true, locale: true }, }); + if (users.length <= cap) return users; + + const latest = await prisma.insightNarrative.groupBy({ + by: ["userId"], + _max: { updatedAt: true }, + }); + const latestByUser = new Map( + latest.map((row) => [ + row.userId, + row._max.updatedAt?.getTime() ?? 0, + ]), + ); + return [...users] + .sort( + (a, b) => (latestByUser.get(a.id) ?? 0) - (latestByUser.get(b.id) ?? 0), + ) + .slice(0, cap); } +/** + * Non-German locales resolve to ENGLISH (matching + * `status-shared.normalizeLocale` and the no-key fallback routing). + */ function normalizeLocale(value: string | null): "de" | "en" { - return value === "en" ? "en" : "de"; + return value === "de" ? "de" : "en"; } /** diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index c59be013c..c277f8ff8 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -50,7 +50,10 @@ import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressur import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; +import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { findStatusCronCandidates } from "@/lib/jobs/status-cron-candidates"; +import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { markWorkerStarted, recordReminderCheck, @@ -281,6 +284,11 @@ const PULSE_STATUS_QUEUE = "insights-pulse-status"; const PULSE_STATUS_CRON = "15 2 * * *"; // daily at 02:15 const BMI_STATUS_QUEUE = "insights-bmi-status"; const BMI_STATUS_CRON = "20 2 * * *"; // daily at 02:20 +// v1.15.20 — mood joins the nightly per-metric status ladder. Same gate + +// discovery as the six older crons (see status-cron-candidates.ts); 02:30 +// continues the 5-minute stagger after BMI (02:20) and compliance (02:25). +const MOOD_STATUS_QUEUE = "insights-mood-status"; +const MOOD_STATUS_CRON = "30 2 * * *"; // daily at 02:30 const MEDICATION_COMPLIANCE_STATUS_QUEUE = "insights-medication-compliance-status"; const MEDICATION_COMPLIANCE_STATUS_CRON = "25 2 * * *"; // daily at 02:25 @@ -470,6 +478,10 @@ interface BmiStatusPayload { triggeredAt: string; } +interface MoodStatusPayload { + triggeredAt?: string; +} + interface MedicationComplianceStatusPayload { triggeredAt: string; } @@ -1252,15 +1264,30 @@ function handleWhoopCycleSync(jobs: Job[]) { return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle); } -async function handleGeneralStatusGenerate(jobs: Job[]) { - void jobs; - await withBackgroundEvent("job.insights.general", async (evt) => { +/** + * Shared driver for the nightly 02:xx per-metric status crons. User + * discovery is centralised in `findStatusCronCandidates`, which applies + * the operator assistant kill-switch, the per-user `disableCoach` gate, + * and the pregenerate-candidate skip (users with a configured provider + * and a stale comprehensive cache belong to the 04:30 pre-generate pass, + * which re-warms every per-status cache anyway — see + * `status-cron-candidates.ts` for the full division of nightly labour). + * The generators normalise `locale` themselves (de stays de, everything + * else gets English prose). + */ +async function runStatusCronGenerate( + taskName: string, + generate: ( + userId: string, + options: { locale: string | null; force: boolean }, + ) => Promise, + options: { recordRun?: boolean } = {}, +): Promise { + await withBackgroundEvent(taskName, async (evt) => { const prisma = getWorkerPrisma(); try { - recordInsightsRun(); - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); + if (options.recordRun) recordInsightsRun(); + const users = await findStatusCronCandidates(prisma); if (users.length === 0) return; @@ -1269,240 +1296,81 @@ async function handleGeneralStatusGenerate(jobs: Job[]) { for (const user of users) { try { - await generateGeneralStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); + await generate(user.id, { locale: user.locale, force: false }); generated++; } catch (error) { failed++; evt.addWarning( - `general-status generation failed for user ${user.id}: ${error}`, + `${taskName} generation failed for user ${user.id}: ${error}`, ); } } evt.setBackground({ - task_name: "job.insights.general", + task_name: taskName, result: { generated, failed, total: users.length }, }); } catch (err) { evt.setError(err); recordError(); + await reportWorkerError(taskName, err); throw err; } }); } -async function handleBloodPressureStatusGenerate( +function handleGeneralStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate( + "job.insights.general", + generateGeneralStatusForUser, + { recordRun: true }, + ); +} + +function handleBloodPressureStatusGenerate( jobs: Job[], ) { void jobs; - await withBackgroundEvent("job.insights.blood_pressure", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generateBloodPressureStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `blood-pressure-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.blood_pressure", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + return runStatusCronGenerate( + "job.insights.blood_pressure", + generateBloodPressureStatusForUser, + ); } -async function handleWeightStatusGenerate(jobs: Job[]) { +function handleWeightStatusGenerate(jobs: Job[]) { void jobs; - await withBackgroundEvent("job.insights.weight", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generateWeightStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `weight-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.weight", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + return runStatusCronGenerate( + "job.insights.weight", + generateWeightStatusForUser, + ); } -async function handlePulseStatusGenerate(jobs: Job[]) { +function handlePulseStatusGenerate(jobs: Job[]) { void jobs; - await withBackgroundEvent("job.insights.pulse", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generatePulseStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `pulse-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.pulse", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + return runStatusCronGenerate( + "job.insights.pulse", + generatePulseStatusForUser, + ); } -async function handleBmiStatusGenerate(jobs: Job[]) { +function handleBmiStatusGenerate(jobs: Job[]) { void jobs; - await withBackgroundEvent("job.insights.bmi", async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generateBmiStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `bmi-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.bmi", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }); + return runStatusCronGenerate("job.insights.bmi", generateBmiStatusForUser); } -async function handleMedicationComplianceStatusGenerate( +function handleMoodStatusGenerate(jobs: Job[]) { + void jobs; + return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser); +} + +function handleMedicationComplianceStatusGenerate( jobs: Job[], ) { void jobs; - await withBackgroundEvent( + return runStatusCronGenerate( "job.insights.medication_compliance", - async (evt) => { - const prisma = getWorkerPrisma(); - try { - const users = await prisma.user.findMany({ - select: { id: true, locale: true }, - }); - - if (users.length === 0) return; - - let generated = 0; - let failed = 0; - - for (const user of users) { - try { - await generateMedicationComplianceStatusForUser(user.id, { - locale: user.locale ?? "de", - force: false, - }); - generated++; - } catch (error) { - failed++; - evt.addWarning( - `medication-compliance-status generation failed for user ${user.id}: ${error}`, - ); - } - } - - evt.setBackground({ - task_name: "job.insights.medication_compliance", - result: { generated, failed, total: users.length }, - }); - } catch (err) { - evt.setError(err); - recordError(); - throw err; - } - }, + generateMedicationComplianceStatusForUser, ); } @@ -2066,6 +1934,13 @@ async function handleInsightPregenerateJob( err instanceof Error ? err.message : String(err) }`, ); + // Surface the failure centrally and rethrow so the queue's retry + // policy (retryLimit 3 + backoff) re-runs the warm — swallowing it + // here left the user's caches cold with zero operator signal. + await reportWorkerError(INSIGHT_PREGENERATE_QUEUE, err, { + mode: "force-warm", + }); + throw err; } } @@ -2082,6 +1957,12 @@ async function handleInsightPregenerateJob( err instanceof Error ? err.message : String(err) }`, ); + // Same contract as the force path: report + rethrow so the nightly + // tick retries instead of silently waiting for the next night. + await reportWorkerError(INSIGHT_PREGENERATE_QUEUE, err, { + mode: "scheduled", + }); + throw err; } }); } @@ -2107,6 +1988,13 @@ async function handleInsightStatusGenerate( err instanceof Error ? err.message : String(err) }`, ); + // Report centrally + rethrow so the enqueue's retry policy + // (retryLimit 3 + backoff) re-runs the generation; the polling + // client otherwise sits on "preparing" with zero operator signal. + await reportWorkerError(INSIGHT_STATUS_GENERATE_QUEUE, err, { + metric: job.data.metric, + }); + throw err; } } }); @@ -2386,6 +2274,10 @@ export async function startReminderWorker() { WEIGHT_STATUS_QUEUE, PULSE_STATUS_QUEUE, BMI_STATUS_QUEUE, + // v1.15.20 — mood joins the nightly status ladder. The queue MUST be + // registered here or pg-boss never provisions it and the 02:30 schedule + // silently no-ops (the v1.4.37 dead-queue class). + MOOD_STATUS_QUEUE, MEDICATION_COMPLIANCE_STATUS_QUEUE, MOODLOG_SYNC_QUEUE, DATA_BACKUP_QUEUE, @@ -2575,18 +2467,39 @@ export async function startReminderWorker() { workerLog("error", "integration-status-null-probe failed", err); } - // Schedule recurring cron jobs - const schedules: [string, string][] = [ + // v1.15.20 — retry policy for the LLM-bound insight queues. A transient + // failure (provider hiccup, pool exhaustion) used to fail the nightly tick + // silently until the NEXT night; three backed-off retries match the + // backfill queues' established shape (see e.g. whoop-backfill.ts). + const insightRetryOptions = { + retryLimit: 3, + retryDelay: 60, + retryBackoff: true, + } as const; + + // Schedule recurring cron jobs. The optional third element carries + // per-queue send options (retry policy) merged into the schedule call. + const schedules: [string, string, Record?][] = [ [QUEUE_NAME, CHECK_INTERVAL_CRON], [WITHINGS_SYNC_QUEUE, WITHINGS_SYNC_CRON], [WITHINGS_ACTIVITY_QUEUE, WITHINGS_ACTIVITY_CRON], [WITHINGS_SLEEP_QUEUE, WITHINGS_SLEEP_CRON], - [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON], - [BLOOD_PRESSURE_STATUS_QUEUE, BLOOD_PRESSURE_STATUS_CRON], - [WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON], - [PULSE_STATUS_QUEUE, PULSE_STATUS_CRON], - [BMI_STATUS_QUEUE, BMI_STATUS_CRON], - [MEDICATION_COMPLIANCE_STATUS_QUEUE, MEDICATION_COMPLIANCE_STATUS_CRON], + [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, insightRetryOptions], + [ + BLOOD_PRESSURE_STATUS_QUEUE, + BLOOD_PRESSURE_STATUS_CRON, + insightRetryOptions, + ], + [WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, insightRetryOptions], + [PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, insightRetryOptions], + [BMI_STATUS_QUEUE, BMI_STATUS_CRON, insightRetryOptions], + // v1.15.20 — mood status nightly, continuing the 02:xx ladder. + [MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, insightRetryOptions], + [ + MEDICATION_COMPLIANCE_STATUS_QUEUE, + MEDICATION_COMPLIANCE_STATUS_CRON, + insightRetryOptions, + ], [MOODLOG_SYNC_QUEUE, MOODLOG_SYNC_CRON], [DATA_BACKUP_QUEUE, DATA_BACKUP_CRON], [RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON], @@ -2653,7 +2566,7 @@ export async function startReminderWorker() { [PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON], // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight // pre-generation. Budget-gated per user inside the handler. - [INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON], + [INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, insightRetryOptions], // v1.7.0 — daily 03:40 Europe/Berlin prune for expired measurement // tombstones. [MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON], @@ -2678,11 +2591,14 @@ export async function startReminderWorker() { // v1.11.0 — nightly 05:05 Europe/Berlin period-narrative warm. The // handler only fans out on a week (Mon) / month (1st) boundary; every // other night is a cheap no-op. Budget-gated per user inside the runner. - [PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON], + [PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, insightRetryOptions], ]; - for (const [name, cron] of schedules) { - await boss.schedule(name, cron, {}, { tz: "Europe/Berlin" }); + for (const [name, cron, sendOptions] of schedules) { + await boss.schedule(name, cron, {}, { + tz: "Europe/Berlin", + ...(sendOptions ?? {}), + }); } // Register the handler @@ -2805,6 +2721,11 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleBmiStatusGenerate, ); + await boss.work( + MOOD_STATUS_QUEUE, + { localConcurrency: 1 }, + handleMoodStatusGenerate, + ); await boss.work( MEDICATION_COMPLIANCE_STATUS_QUEUE, { localConcurrency: 1 }, @@ -3010,7 +2931,9 @@ export async function startReminderWorker() { } } catch (err) { recordError(); - workerLog("error", "[period-narrative] warm failed", err); + await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, { + mode: job.data?.userId ? "single-user" : "scheduled", + }); throw err; } } @@ -3339,7 +3262,7 @@ export async function startReminderWorker() { ); } catch (err) { recordError(); - workerLog("error", "[drain-cumulative] run failed", err); + await reportWorkerError("drain-cumulative", err); } // v1.8.5 — fold the daily-MEAN drain onto the same nightly tick as @@ -3365,7 +3288,9 @@ export async function startReminderWorker() { ); } catch (err) { recordError(); - workerLog("error", "[mean-consolidation] nightly run failed", err); + await reportWorkerError("mean-consolidation", err, { + tick: "nightly", + }); } // v1.10.0 WX-E — fold the dense intra-day retention drain onto the @@ -3400,11 +3325,9 @@ export async function startReminderWorker() { } } catch (err) { recordError(); - workerLog( - "error", - "[dense-intraday-retention] nightly run failed", - err, - ); + await reportWorkerError("dense-intraday-retention", err, { + tick: "nightly", + }); } } }, diff --git a/src/lib/jobs/report-worker-error.ts b/src/lib/jobs/report-worker-error.ts new file mode 100644 index 000000000..f10e9fc75 --- /dev/null +++ b/src/lib/jobs/report-worker-error.ts @@ -0,0 +1,76 @@ +/** + * Central error reporter for pg-boss worker handlers. + * + * Worker errors used to be visible only as a stderr line (`workerLog`) + * — they never reached GlitchTip, so a nightly pass that failed for + * weeks was invisible unless the operator happened to read container + * logs. This helper gives every worker catch the same two sinks the + * HTTP layer has: + * + * 1. stderr (the operator's container log), and + * 2. GlitchTip, through the same `sendGlitchtipEvent` payload shape + * `api-handler.ts` uses, with the same redaction + * (`redactSecrets` on the message, `redactOptional` on the stack) + * so a Telegram bot token or Bearer credential embedded in an + * error message cannot leak into the incident UI. + * + * Fire-and-forget by contract: the reporter NEVER throws, so a sink + * failure (GlitchTip down, settings read failing) cannot mask or + * replace the original handler error. + */ +import { redactOptional, redactSecrets } from "@/lib/logging/redact"; + +/** + * Report one worker error. `queue` is the pg-boss queue (or logical + * pass) name; `meta` is a small pinned-shape key/value bag folded into + * the message tail (GlitchTip's event payload has no free-form extras + * field) — keep values to identifiers and counts, never payload data. + */ +export async function reportWorkerError( + queue: string, + error: unknown, + meta: Record = {}, +): Promise { + const err = + error instanceof Error + ? error + : new Error(typeof error === "string" ? error : "Unknown worker error"); + + const metaEntries = Object.entries(meta); + const metaSuffix = + metaEntries.length > 0 + ? ` (${metaEntries.map(([k, v]) => `${k}=${v}`).join(" ")})` + : ""; + const message = redactSecrets(`[${queue}] ${err.message}${metaSuffix}`); + + // Sink 1 — stderr, mirroring `workerLog("error", …)` so the operator + // keeps the container-log trail even when GlitchTip is disabled. + console.error(`[pg-boss] ${message}`, err); + + // Sink 2 — GlitchTip, fire-and-forget. Dynamic imports mirror the + // api-handler forwarder (no startup cost, no import cycle). + try { + const [{ getGlitchtipSettings }, { sendGlitchtipEvent }] = + await Promise.all([ + import("@/lib/monitoring-settings"), + import("@/lib/monitoring/glitchtip"), + ]); + const settings = await getGlitchtipSettings(); + if (!settings.glitchtipEnabled || !settings.glitchtipDsn) return; + + await sendGlitchtipEvent({ + dsn: settings.glitchtipDsn, + input: { + environment: settings.glitchtipEnvironment || "production", + message, + level: "error", + type: err.name || "Error", + stack: redactOptional(err.stack), + sourceTag: "healthlog-worker", + }, + }); + } catch { + // The reporter must never throw — a sink failure would otherwise + // mask the original handler error. + } +} diff --git a/src/lib/jobs/status-cron-candidates.ts b/src/lib/jobs/status-cron-candidates.ts new file mode 100644 index 000000000..17d5c53b3 --- /dev/null +++ b/src/lib/jobs/status-cron-candidates.ts @@ -0,0 +1,98 @@ +/** + * Shared user discovery for the nightly 02:xx per-metric status crons + * (general / blood-pressure / weight / pulse / bmi / medication- + * compliance / mood). + * + * Before this module each cron iterated EVERY user row: accounts that + * disabled the coach surface (`disableCoach`) and operator-disabled + * deployments (assistant kill-switch) still paid one generator pass per + * user per night, and users the 04:30 `insight-pregenerate` cron was + * going to re-warm anyway were generated twice. + * + * Division of nightly labour (documented here once, referenced by the + * handlers): + * + * - 02:xx status crons → users WITHOUT a comprehensive-pregenerate + * claim: no configured provider, or a comprehensive cache fresh + * enough (< 20 h) that the 04:30 pass will skip them — their + * per-status caches still age out daily and only these crons + * re-fill them. + * - 04:30 insight-pregenerate → the rest: coach-enabled users with a + * configured provider and a stale (>= 20 h) comprehensive cache. + * That pass regenerates the comprehensive insight AND force-warms + * every per-status cache, so a 02:xx generation for those users + * would be evicted and redone two hours later. + * + * Gates, in order: + * 1. Operator kill-switch via `getAssistantFlags()` — the same source + * `insight-pregenerate` uses. The status cards belong to the + * `insightStatus` surface, so that flag (which the master assistant + * switch also forces off) gates the whole pass. + * 2. Per-user `disableCoach: false` — a user who disabled the AI + * surface gets no nightly generation. + * 3. The pregenerate-candidate skip described above (only when the + * `briefing` surface is enabled — when it is off the 04:30 pass + * no-ops, so the 02:xx crons must keep covering everyone). + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { getAssistantFlags } from "@/lib/feature-flags"; +import { userRowHasProviderCredential } from "@/lib/ai/provider"; +import { PREGENERATE_STALE_MS } from "@/lib/jobs/insight-pregenerate"; + +export interface StatusCronCandidate { + id: string; + locale: string | null; +} + +export async function findStatusCronCandidates( + prisma: PrismaClient, + now: Date = new Date(), +): Promise { + const flags = await getAssistantFlags(); + if (!flags.insightStatus) return []; + + const users = await prisma.user.findMany({ + where: { disableCoach: false }, + select: { + id: true, + locale: true, + insightsCachedAt: true, + // Credential-presence columns for the pregenerate-candidate skip — + // evaluated locally, never decrypted. + aiProvider: true, + aiProviderChain: true, + aiAnthropicKeyEncrypted: true, + aiLocalKeyEncrypted: true, + aiOpenaiKeyEncrypted: true, + aiBaseUrl: true, + codexConnectionStatus: true, + codexAccessTokenEncrypted: true, + codexRefreshTokenEncrypted: true, + }, + }); + if (users.length === 0) return []; + + // When the briefing surface is off the 04:30 pregenerate pass no-ops, + // so no user has a comprehensive claim and the 02:xx crons keep all. + if (!flags.briefing) { + return users.map((u) => ({ id: u.id, locale: u.locale })); + } + + // One presence read for the operator's shared key covers the cohort. + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { adminAiKeyEncrypted: true }, + }); + const adminKeyConfigured = !!settings?.adminAiKeyEncrypted; + + const staleBefore = now.getTime() - PREGENERATE_STALE_MS; + return users + .filter((u) => { + const cacheStale = + !u.insightsCachedAt || u.insightsCachedAt.getTime() < staleBefore; + const isPregenerateCandidate = + cacheStale && userRowHasProviderCredential(u, adminKeyConfigured); + return !isPregenerateCandidate; + }) + .map((u) => ({ id: u.id, locale: u.locale })); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 18d216228..a9ac29a3b 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -191,6 +191,12 @@ const profileUpdateRequest = z displayName: z.string().min(1).max(80).nullable().optional(), locale: z.enum(["de", "en"]).nullable().optional(), timezone: z.string().min(1).max(64).optional(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .optional() + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), moodReminderEnabled: z.boolean().optional(), fullName: z.string().max(120).nullable().optional(), insurerName: z.string().max(120).nullable().optional(), @@ -223,6 +229,11 @@ const profileResponse = z heightCm: z.number().nullable(), locale: z.string().nullable(), timezone: z.string(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), @@ -253,6 +264,11 @@ const profileUpdateResponse = z heightCm: z.number().nullable(), locale: z.string().nullable(), timezone: z.string(), + timeFormat: z + .enum(["AUTO", "H12", "H24"]) + .describe( + "Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.", + ), moodReminderEnabled: z.boolean(), fullName: z.string().nullable(), insurerName: z.string().nullable(), @@ -1987,14 +2003,19 @@ const dashboardSnapshotResponse = z }) .nullable(), briefing: z.record(z.string(), z.unknown()).nullable(), - briefingState: z.enum(["ready", "preparing", "disabled"]), + briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), briefingUpdatedAt: z.string().nullable(), + briefingStale: z + .boolean() + .describe( + "True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.", + ), generatedAt: z.string(), }) .meta({ id: "DashboardSnapshotResponse", description: - "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` via `briefingState`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.", + "Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.", }); // v1.5.0 — natural-language medication extraction route. The wizard's