chore(release): v1.28.30 — the daily briefing stops silently skipping days

The recurring no-briefing day had a chain of causes, closed end to end:

Every failure path in the nightly pregenerate + the comprehensive generator
now annotates a queryable cause (several classes — features-error,
payload-too-large, aborted, generator-failed — previously bumped a counter
with zero trace). A failed nightly warm enqueues one bounded retry ~45
minutes later (singleton per user, existing budgets apply), so a transient
provider hiccup heals before morning. A cached payload that carries NO
briefing no longer satisfies the 24h POST short-circuit nor the
unchanged-content hash gate — a once-stripped briefing could previously
block its own regeneration indefinitely. The card surfaces the omission
reason persistently (grounding strip, generation failure, not attempted)
with a forcing retry.

Documentation corrected against code truth: Withings credentials are
per-user (the documented env path never existed; the dead example entries
and manifest rows are gone, and WITHINGS_WEBHOOK_SECRET is now actually
whitelisted in compose — stock deploys could never deliver it); the classic
Fitbit portal no longer accepts new registrations; the backup-restore
example no longer mints a fresh encryption key and uses pnpm dlx tsx; the
TLS guide describes the real client model (two CA-level SPKI pins,
any-match, leaf renewals need no build); scaling/admin references fixed;
the operator-shared subscription provider is documented.
This commit is contained in:
Marc-André Bombeck committed 2026-07-12 11:08:59 +02:00
1 parent 14a517387e
commit a4feabec81
26 files changed
+1190 -148

No files matched your search

@@ -405,11 +405,16 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
it("does NOT touch the cache when serving from the 24h DB cache", async () => {
// Cached path: route returns early without touching the LLM or the
// cache write.
// cache write. v1.28.30 — the short-circuit requires the cached
// payload to CARRY a briefing (a briefingless cache is stale for a
// briefing-expecting caller), so the fixture carries one.
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
insightsCachedText: JSON.stringify({
changed: "still fresh",
dailyBriefing: { paragraph: "ok", keyFindings: [] },
}),
locale: "en",
} as never);
@@ -445,7 +450,10 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(),
insightsCachedText: JSON.stringify({ changed: "still fresh" }),
insightsCachedText: JSON.stringify({
changed: "still fresh",
dailyBriefing: { paragraph: "ok", keyFindings: [] },
}),
locale: "en",
} as never);
@@ -467,6 +475,99 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
});
});
// v1.28.30 — the "no briefing today" chain: a failed nightly warm left a
// fresh-but-briefingless cache inside the 24 h window, and the POST's
// unconditional short-circuit served it to every regenerate attempt
// (`cached: true` three times in prod, zero generations, a full day
// without a briefing). The short-circuit now only holds when the cached
// payload carries a briefing OR the account has no usable provider.
describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", () => {
function mockBriefinglessFreshCache() {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({
changed: "fresh but no briefing",
dailyBriefing: null,
}),
insightsExcludeMetrics: [],
locale: "en",
} as never);
}
it("POST without force + fresh cache WITH briefing → served cached", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsPrivacyMode: "aggregated",
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "today", keyFindings: [] },
}),
locale: "en",
} as never);
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("POST without force + fresh cache WITHOUT briefing + provider → regenerates", async () => {
makeWorkingProvider();
mockBriefinglessFreshCache();
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
// The stale-for-briefing cache was bypassed: a real generation ran and
// wrote a fresh cache row.
expect(body.data.cached).toBe(false);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("POST without force + fresh cache WITHOUT briefing + NO provider → serves cached (regeneration is futile)", async () => {
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
mockBriefinglessFreshCache();
const res = await POST(jsonRequest({}) as never);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("degrades to the cached payload instead of 429 when the briefingless fall-through is rate-limited", async () => {
makeWorkingProvider();
mockBriefinglessFreshCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
const res = await POST(jsonRequest({}) as never);
// A POST-as-read caller never used to see a 429 on a fresh cache;
// exhausting the quota falls back to the old cached-serve behaviour.
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cached: boolean } };
expect(body.data.cached).toBe(true);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("explicit force keeps the honest 429 when rate-limited", async () => {
makeWorkingProvider();
mockBriefinglessFreshCache();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
const res = await POST(jsonRequest({ force: true }) as never);
expect(res.status).toBe(429);
});
});
// v1.4.36 QA H1 — when both the raw-mode extraction AND the aggregated
// retry cross the 5 MB ceiling, the route used to throw uncaught → 500.
// It now wraps the retry, downgrades further via the exclude filter,
@@ -619,6 +720,66 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
expect(body.data.revalidating).toBe(false);
});
// v1.28.30 — a briefing the grounding gate withheld used to be visible
// only on the transient POST response; every later read showed a generic
// "no briefing yet". The marker written after the cache write now
// surfaces the omission on the read path, WITHOUT reading as a failure.
it("surfaces briefingOmittedReason from a briefing-ungrounded marker (not as a failure)", async () => {
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: cachedAt,
insightsCachedText: JSON.stringify({ dailyBriefing: null }),
locale: "en",
} as never);
// Marker newer than the last successful generation (written right
// after the cache write on the strip path).
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
createdAt: new Date(cachedAt.getTime() + 1000),
details: JSON.stringify({ reason: "briefing-ungrounded" }),
} as never);
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
briefingOmittedReason: string | null;
generationFailed: boolean;
generationFailureClass: string | null;
};
};
expect(body.data.briefingOmittedReason).toBe("ungrounded");
// The generation SUCCEEDED (the briefing was withheld) — the card must
// render "withheld", not "couldn't generate".
expect(body.data.generationFailed).toBe(false);
expect(body.data.generationFailureClass).toBeNull();
});
it("still reports generationFailed for a genuine failure marker", async () => {
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: cachedAt,
insightsCachedText: JSON.stringify({
dailyBriefing: { paragraph: "held", keyFindings: [] },
}),
locale: "en",
} as never);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
createdAt: new Date(cachedAt.getTime() + 1000),
details: JSON.stringify({ reason: "provider-error", httpStatus: 503 }),
} as never);
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
new Request("http://localhost/api/insights/generate"),
);
const body = (await res.json()) as {
data: { briefingOmittedReason: string | null; generationFailed: boolean };
};
expect(body.data.generationFailed).toBe(true);
expect(body.data.briefingOmittedReason).toBeNull();
});
it("reports hasProvider: true on a normal cached read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
insightsCachedAt: new Date(),
+93 -10
View File
@@ -81,6 +81,7 @@ import {
buildComparisonSnapshotForUser,
enqueueStatusRefillForUser,
} from "@/lib/insights/comprehensive-generate";
import { cachedPayloadCarriesBriefing } from "@/lib/insights/briefing-payload";
export const dynamic = "force-dynamic";
@@ -168,10 +169,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
userId,
since: cachedAt,
});
const generationFailed = briefingFailure !== null;
// v1.28.30 — a `briefing-ungrounded` marker is NOT a failure: the
// generation succeeded but the grounding gate withheld the briefing
// (marker written after the cache write, so it survives the `since`
// filter). Surface it as the persistent omission signal — the card's
// "briefing withheld" state used to exist only on the transient POST
// response, so a nightly strip read as a silent generic empty card.
const briefingOmittedReason: "ungrounded" | null =
briefingFailure?.reason === "briefing-ungrounded" ? "ungrounded" : null;
const generationFailed =
briefingFailure !== null && briefingOmittedReason === null;
// v1.25.3 — the failure class lets the empty state point its hint at the
// right lever (raise the response timeout vs re-check the provider).
const generationFailureClass = briefingFailure?.failureClass ?? null;
const generationFailureClass = generationFailed
? (briefingFailure?.failureClass ?? null)
: null;
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
@@ -222,6 +234,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// v1.25.3 — coarse failure class for the empty-state hint (null when
// the last attempt succeeded).
generationFailureClass,
// v1.28.30 — persistent grounding-omission signal (see above), so a
// nightly-stripped briefing renders "withheld", not "no briefing yet".
briefingOmittedReason,
});
} catch {
// Invalid cache row — fall through to the empty payload below. The
@@ -246,6 +261,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
generationFailed,
// v1.25.3 — coarse failure class for the empty-state hint.
generationFailureClass,
// v1.28.30 — see the cached branch above.
briefingOmittedReason,
});
});
@@ -301,6 +318,24 @@ export const POST = apiHandler((request: NextRequest) =>
if (jsonError) return jsonError;
const forceRefresh = body.force === true;
// v1.28.30 — a fresh cache WITHOUT a briefing must not satisfy a
// briefing-expecting caller: after a failed nightly warm the cached
// payload can sit inside the 24 h window all day, and the old
// unconditional short-circuit served it to every POST (three
// regenerate attempts, three `cached: true` responses, zero
// generations). The short-circuit now holds only when the cached
// payload actually carries a briefing OR the account has no usable
// provider (regenerating would be futile — serve what exists). The
// read-only GET is untouched: it never generates and its out-of-band
// revalidation behaviour stays as-is. When the briefingless fall-
// through is rate-limited below, the cached payload is served after
// all so a POST-as-read client degrades to the old behaviour instead
// of a 429.
let briefinglessCacheFallback: {
cached: unknown;
legacyPayload: boolean;
cachedAt: Date;
} | null = null;
if (
!forceRefresh &&
dbUser?.insightsCachedAt &&
@@ -314,15 +349,33 @@ export const POST = apiHandler((request: NextRequest) =>
// We don't auto-regenerate — that would burn rate-limit tokens
// on a cache-hit silently. User-initiated only.
const legacyPayload = isLegacyInsightPayload(cached);
annotate({
action: { name: "insights.generate" },
meta: { cached: true, legacyPayload },
});
return apiSuccess({
insights: cached,
cached: true,
cachedAt: dbUser.insightsCachedAt,
const carriesBriefing = cachedPayloadCarriesBriefing(
dbUser.insightsCachedText,
);
if (carriesBriefing || !(await hasUsableStatusProvider(userId))) {
annotate({
action: { name: "insights.generate" },
meta: {
cached: true,
legacyPayload,
briefingless: !carriesBriefing,
},
});
return apiSuccess({
insights: cached,
cached: true,
cachedAt: dbUser.insightsCachedAt,
legacyPayload,
});
}
briefinglessCacheFallback = {
cached,
legacyPayload,
cachedAt: dbUser.insightsCachedAt,
};
annotate({
action: { name: "insights.generate.briefingless_cache_bypassed" },
meta: { locale },
});
} catch {
// Invalid cache, regenerate
@@ -346,6 +399,22 @@ export const POST = apiHandler((request: NextRequest) =>
60 * 60 * 1000,
);
if (!rl.allowed) {
// v1.28.30 — a POST-as-read caller whose fresh-but-briefingless cache
// fell through to regeneration degrades to the cached payload when
// the hourly quota is exhausted, instead of surfacing a 429 it never
// used to see. Explicit `force` keeps the honest 429.
if (briefinglessCacheFallback !== null) {
annotate({
action: { name: "insights.generate" },
meta: { cached: true, rate_limited_fallback: true },
});
return apiSuccess({
insights: briefinglessCacheFallback.cached,
cached: true,
cachedAt: briefinglessCacheFallback.cachedAt,
legacyPayload: briefinglessCacheFallback.legacyPayload,
});
}
return apiError(`Maximum ${limit} insight generations per hour.`, 429);
}
@@ -855,6 +924,20 @@ export const POST = apiHandler((request: NextRequest) =>
},
});
// v1.28.30 — persist the grounding omission (marker AFTER the cache
// write so it is newer than `insightsCachedAt`). The transient
// `briefingOmittedReason` on this response only reached the caller that
// forced the regenerate; every later read showed a generic "no briefing
// yet". The dated marker lets the GET surface the same honest
// "briefing withheld" state until the next successful generation.
if (briefingOmittedReason === "ungrounded") {
void recordBriefingFailure({
userId,
reason: "briefing-ungrounded",
locale,
});
}
// v1.16.8 — no blanket per-status eviction here any more (nuking ~45
// cache rows per manual regenerate deleted every hash baseline and was
// the source of the post-regenerate card-regeneration storm). Instead,