mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user