mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
refactor(insights): drop the readable cached-text column
Every write has gone to insights_cached_text_encrypted since 0411, and the boot-time free-text backfill sealed the rows written before it. Migration 0430 drops insights_cached_text and the backfill index that served it. A host that skipped the intervening releases loses only a cache of model output; the next warm writes a fresh, sealed payload. The cipher module now reads and writes the sealed column only, the backfill job loses its insights step, and consent withdrawal, the wipe plan, the backup plan and the session projection follow the schema. Backups never carried the column, and restore ignores unknown account settings; a round-trip test now proves an old backup naming the field still restores cleanly. The access guard pins exact counts for where the sealed name may appear. Fixtures, the demo seed and the demo migration script seed the sealed column instead of plaintext.
This commit is contained in:
1 parent
45da8ae971
commit
9941db1549
49 files changed
+381
-497
No files matched your search
@@ -226,7 +226,6 @@ describe("about-me module gate", () => {
|
||||
where: { id: "u1" },
|
||||
data: {
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedTextEncrypted: null,
|
||||
insightsCachedLocale: null,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* The read seam for the unified daily-value system (P3). Returns the
|
||||
* `DailyDigest` DTO the Today surface, the daily push, and a future iOS widget
|
||||
* all consume — assembled by `loadDailyDigest` from ALREADY-CACHED data (the
|
||||
* nightly briefing lifted read-only from `User.insightsCachedText`, the
|
||||
* nightly briefing lifted read-only from `User.insightsCachedTextEncrypted`, the
|
||||
* dashboard-snapshot health score / meds-today / sleep freshness) plus two
|
||||
* light deterministic reads (broken integrations, overdue Vorsorge). No
|
||||
* provider call is reachable from this path, and nothing warms on mount.
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
* so a regression is attributable without re-instrumenting.
|
||||
*
|
||||
* No LLM is reachable from the builder — the daily briefing is lifted
|
||||
* read-only from `User.insightsCachedText`. The nightly
|
||||
* read-only from `User.insightsCachedTextEncrypted`. The nightly
|
||||
* `insight-pregenerate` cron keeps that cache warm.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
|
||||
@@ -206,7 +206,7 @@ beforeEach(() => {
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insuranceNumberEncrypted: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedTextEncrypted: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock("@/lib/db", () => ({
|
||||
findUnique: vi.fn(async () => ({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedTextEncrypted: null,
|
||||
locale: "en",
|
||||
})),
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
@@ -175,7 +175,10 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildUserPrompt } from "@/lib/ai/prompts/insight-system-prompt";
|
||||
import { AiUnavailableError } from "@/lib/ai/capabilities/refusal";
|
||||
import { openInsightsCachedText } from "@/lib/insights/cached-text-cipher";
|
||||
import {
|
||||
openInsightsCachedText,
|
||||
sealInsightsCachedText,
|
||||
} from "@/lib/insights/cached-text-cipher";
|
||||
|
||||
// The cached payload is sealed at rest, so its writes need a key.
|
||||
vi.stubEnv("ENCRYPTION_KEY", "c".repeat(64));
|
||||
@@ -472,7 +475,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
const user = {
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedTextEncrypted: null,
|
||||
locale: "en",
|
||||
unitPreference: "imperial",
|
||||
glucoseUnit: "mmol/L",
|
||||
@@ -527,7 +530,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedTextEncrypted: null,
|
||||
insightsExcludeMetrics: ["sleep"],
|
||||
locale: "en",
|
||||
dateOfBirth: null,
|
||||
@@ -564,7 +567,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedTextEncrypted: null,
|
||||
insightsExcludeMetrics: [],
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -596,11 +599,13 @@ 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",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({
|
||||
changed: "still fresh",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -636,11 +641,13 @@ 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",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({
|
||||
changed: "still fresh",
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -673,10 +680,12 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
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,
|
||||
}),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({
|
||||
changed: "fresh but no briefing",
|
||||
dailyBriefing: null,
|
||||
}),
|
||||
),
|
||||
insightsExcludeMetrics: [],
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -686,10 +695,12 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)",
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "today", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({
|
||||
dailyBriefing: { paragraph: "today", keyFindings: [] },
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -770,7 +781,7 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", ()
|
||||
// wide shape; the retry asks for the aggregated shape.
|
||||
insightsPrivacyMode: "raw",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedTextEncrypted: null,
|
||||
insightsExcludeMetrics: [],
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -817,7 +828,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify(cached),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -842,7 +855,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: stale,
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: null }),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({ dailyBriefing: null }),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -867,7 +882,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
const cached = { dailyBriefing: { paragraph: "Deutsch", keyFindings: [] } };
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify(cached),
|
||||
),
|
||||
insightsCachedLocale: "de",
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -896,7 +913,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify(cached),
|
||||
),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
} as never);
|
||||
@@ -918,10 +937,12 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(Date.now() - 60_000),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] },
|
||||
briefingGeneratedAt: yesterday.toISOString(),
|
||||
}),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({
|
||||
dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] },
|
||||
briefingGeneratedAt: yesterday.toISOString(),
|
||||
}),
|
||||
),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
@@ -944,29 +965,31 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
it("serves today's signals with deltas at display precision", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: {
|
||||
paragraph: "ok",
|
||||
keyFindings: [],
|
||||
signalsOfDay: [
|
||||
{
|
||||
sourceMetric: "sleep",
|
||||
tone: "info",
|
||||
headline: "Shorter night",
|
||||
nudge: "An earlier night.",
|
||||
delta: null,
|
||||
},
|
||||
{
|
||||
sourceMetric: "pulse",
|
||||
tone: "watch",
|
||||
headline: "Pulse up",
|
||||
nudge: "Rest.",
|
||||
delta: "+33.72 bpm vs your 30-day average",
|
||||
},
|
||||
],
|
||||
},
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({
|
||||
dailyBriefing: {
|
||||
paragraph: "ok",
|
||||
keyFindings: [],
|
||||
signalsOfDay: [
|
||||
{
|
||||
sourceMetric: "sleep",
|
||||
tone: "info",
|
||||
headline: "Shorter night",
|
||||
nudge: "An earlier night.",
|
||||
delta: null,
|
||||
},
|
||||
{
|
||||
sourceMetric: "pulse",
|
||||
tone: "watch",
|
||||
headline: "Pulse up",
|
||||
nudge: "Rest.",
|
||||
delta: "+33.72 bpm vs your 30-day average",
|
||||
},
|
||||
],
|
||||
},
|
||||
briefingGeneratedAt: new Date().toISOString(),
|
||||
}),
|
||||
),
|
||||
insightsCachedLocale: "en",
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
@@ -998,7 +1021,7 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsCachedTextEncrypted: null,
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -1027,7 +1050,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
const cached = { dailyBriefing: { paragraph: "old", keyFindings: [] } };
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: stale,
|
||||
insightsCachedText: JSON.stringify(cached),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify(cached),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
@@ -1054,7 +1079,9 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: cachedAt,
|
||||
insightsCachedText: JSON.stringify({ dailyBriefing: null }),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({ dailyBriefing: null }),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
// Marker newer than the last successful generation (written right
|
||||
@@ -1086,9 +1113,11 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
const cachedAt = new Date(Date.now() - 60 * 60 * 1000);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: cachedAt,
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "held", keyFindings: [] },
|
||||
}),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({
|
||||
dailyBriefing: { paragraph: "held", keyFindings: [] },
|
||||
}),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
|
||||
@@ -1109,9 +1138,11 @@ describe("GET /api/insights/generate — read-only advisor read", () => {
|
||||
it("reports hasProvider: true on a normal cached read", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
insightsCachedAt: new Date(),
|
||||
insightsCachedText: JSON.stringify({
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
}),
|
||||
insightsCachedTextEncrypted: sealInsightsCachedText(
|
||||
JSON.stringify({
|
||||
dailyBriefing: { paragraph: "ok", keyFindings: [] },
|
||||
}),
|
||||
),
|
||||
locale: "en",
|
||||
} as never);
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export function resolveInsightsRateLimit(): number {
|
||||
* to mount against the POST handler, which generates inline on a cache miss
|
||||
* and blocks the page-load path on the full provider chain (up to the
|
||||
* client's 8 s abort). This read-only GET serves the cached payload from
|
||||
* `User.insightsCachedText` immediately — NEVER calling the provider — and,
|
||||
* `User.insightsCachedTextEncrypted` immediately — NEVER calling the provider — and,
|
||||
* when the cache is stale / missing AND a provider is configured, enqueues
|
||||
* an out-of-band warm (the same `insight-pregenerate` queue the nightly
|
||||
* cron and the "prepare assessments" button use). The next read reflects
|
||||
|
||||
Reference in new issue
Block a user