fix(insights): honest assistant pipeline — gates, tombstones, retries, truthful states

The two-o'clock status crons generated for every account, ignoring both
the per-user coach opt-out and the operator kill switch; they now share
one gated discovery that also skips users the pregenerate pass will
rewarm anyway, and the mood card finally gets the nightly run the other
six always had. Deleted measurements no longer feed any prompt. Locales
without an assistant language fall back to English consistently instead
of German texts with English fallbacks. A rate-limited regenerate stops
claiming success, the dashboard briefing state distinguishes a missing
provider from a pending generation and serves a stale briefing with its
timestamp instead of hiding it, narrative warming actually processes
oldest first, and worker failures reach the error tracker through one
shared reporter while the insight queues gain real retries.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 13:34:37 +02:00
1 parent 0a4bf34a2b
commit 3cf48fa0f1
32 files changed
+1194 -325

No files matched your search

+1
View File
@@ -147,6 +147,7 @@ export function buildMockSnapshot(
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
briefingStale: false,
generatedAt: now,
};
}
+4
View File
@@ -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: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
@@ -202,4 +202,33 @@ describe("<DailyBriefing>", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
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(
<DailyBriefing briefing={null} onRegenerate={() => {}} 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(
<DailyBriefing briefing={null} noProvider />,
"de",
);
expect(html).toContain("Kein KI-Anbieter verbunden");
});
it("prefers the briefing content over the no-provider hint when both exist", () => {
const html = render(
<DailyBriefing briefing={baseBriefing} noProvider />,
);
expect(html).toContain('data-slot="daily-briefing-findings"');
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
});
});
@@ -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({
</p>
)}
</div>
) : noProvider ? (
// v1.15.20 — no provider configured anywhere: a regenerate CTA
// would 422 forever, so point at Settings → AI instead.
<EmptyState
data-slot="daily-briefing-no-provider"
variant="plain"
icon={<Sparkles className="size-5" />}
title={t("insights.dailyBriefing.noProviderTitle")}
description={t("insights.dailyBriefing.noProviderDescription")}
action={
<Button
type="button"
size="sm"
variant="outline"
asChild
data-slot="daily-briefing-no-provider-cta"
>
<Link href="/settings/ai">
{t("insights.dailyBriefing.noProviderAction")}
</Link>
</Button>
}
/>
) : (
<EmptyState
data-slot="daily-briefing-empty"
+25 -10
View File
@@ -79,10 +79,18 @@ export interface InsightsTabStripProps {
* toast fires "refreshed" ONLY when this is `"fresh"`; a slow generation the
* client gave up on (`"timeout"`) shows a "still working" hint instead of a
* misleading success, and a missing provider (`"no-provider"`) stays silent
* (the surface already shows the connect-AI empty state). Absent (legacy
* (the surface already shows the connect-AI empty state). v1.15.20 —
* `"rate-limited"` (quota exhausted) and `"empty"` (transient 503) show
* their own honest hints instead of the success toast. Absent (legacy
* mounts / no regenerate) falls back to the prior unconditional success.
*/
regenerateOutcome?: "fresh" | "empty" | "timeout" | "no-provider" | null;
regenerateOutcome?:
| "fresh"
| "empty"
| "rate-limited"
| "timeout"
| "no-provider"
| null;
/**
* v1.4.27 F19 — analytics + event-driven availability inputs the
* gating helper reads. When omitted the strip falls back to its
@@ -486,20 +494,27 @@ function InsightsTabStripImpl({
// Fire a toast on the falling edge of `regenerating`. Same rising-edge ref
// guard as the W3 implementation so the toast fires exactly once per
// regenerate cycle. v1.15.18 — the toast is now HONEST: only a `"fresh"`
// outcome reads "refreshed". A `"timeout"` (a slow generation the client
// gave up on while the server may still be writing) shows a "still working,
// try again" hint rather than claiming success; a `"no-provider"` stays
// silent (the surface already shows the connect-AI empty state). The latest
// outcome is read through a ref so the effect doesn't re-fire when only the
// outcome (not the spinner edge) changed.
// regenerate cycle. v1.15.18 — the toast is HONEST: only a `"fresh"`
// outcome reads "refreshed". v1.15.20 — the honesty extends to the
// non-fresh outcomes: `"rate-limited"` (429) says the quota is exhausted,
// `"empty"` (transient 503) says nothing fresh arrived, `"timeout"` keeps
// its "still working, try again" hint, and `"no-provider"` stays silent
// (the surface already shows the connect-AI empty state). Previously
// `"empty"` lumped 429 + 503 together AND toasted success — a rate-limited
// regenerate claimed "refreshed" while serving the old text. The latest
// outcome is read through a ref-free dep so the effect fires only on the
// spinner edge.
const lastRegeneratingRef = useRef<boolean>(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"));
}
}
+34 -14
View File
@@ -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,
};
}
@@ -56,6 +56,7 @@ function buildUser(disableCoach: boolean): AuthUser {
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
+2 -1
View File
@@ -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 },
})
+110
View File
@@ -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<boolean> {
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.
+60 -9
View File
@@ -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.
+88 -45
View File
@@ -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<Record<MeasurementType, string>> = {
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<string, unknown>;
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<boolean>,
): 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?: <T>(label: string, fn: () => Promise<T>) => Promise<T>;
/**
* 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<boolean>;
} = {},
): Promise<DashboardSnapshot> {
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(),
};
}
@@ -87,6 +87,7 @@ function buildUser(disableCoach: boolean): AuthUser {
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
@@ -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");
});
});
+2
View File
@@ -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,
+4 -1
View File
@@ -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) {
+2
View File
@@ -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,
+7 -2
View File
@@ -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 {
+2
View File
@@ -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" },
@@ -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*\]/,
);
});
@@ -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(
@@ -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*\]/,
);
});
@@ -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");
});
});
@@ -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> = {}): 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/);
});
});
@@ -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({
+6 -1
View File
@@ -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";
}
/**
@@ -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" },
+5
View File
@@ -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({
+30 -3
View File
@@ -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<NarrativeCandidate[]> {
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<string, number>(
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";
}
/**
+147 -224
View File
@@ -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<WhoopSyncPayload>[]) {
return runWhoopResourceSync("job.whoop_cycle_sync", jobs, syncUserCycle);
}
async function handleGeneralStatusGenerate(jobs: Job<GeneralStatusPayload>[]) {
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<unknown>,
options: { recordRun?: boolean } = {},
): Promise<void> {
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<GeneralStatusPayload>[]) {
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<GeneralStatusPayload>[]) {
void jobs;
return runStatusCronGenerate(
"job.insights.general",
generateGeneralStatusForUser,
{ recordRun: true },
);
}
function handleBloodPressureStatusGenerate(
jobs: Job<BloodPressureStatusPayload>[],
) {
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<WeightStatusPayload>[]) {
function handleWeightStatusGenerate(jobs: Job<WeightStatusPayload>[]) {
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<PulseStatusPayload>[]) {
function handlePulseStatusGenerate(jobs: Job<PulseStatusPayload>[]) {
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<BmiStatusPayload>[]) {
function handleBmiStatusGenerate(jobs: Job<BmiStatusPayload>[]) {
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<MoodStatusPayload>[]) {
void jobs;
return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser);
}
function handleMedicationComplianceStatusGenerate(
jobs: Job<MedicationComplianceStatusPayload>[],
) {
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<string, unknown>?][] = [
[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<MoodStatusPayload>(
MOOD_STATUS_QUEUE,
{ localConcurrency: 1 },
handleMoodStatusGenerate,
);
await boss.work<MedicationComplianceStatusPayload>(
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",
});
}
}
},
+76
View File
@@ -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<string, string | number | boolean> = {},
): Promise<void> {
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.
}
}
+98
View File
@@ -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<StatusCronCandidate[]> {
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 }));
}
+23 -2
View File
@@ -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