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