diff --git a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts index f37ced16d..c26ee7d95 100644 --- a/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts +++ b/src/lib/insights/__tests__/prompt-soft-delete-filter.test.ts @@ -50,4 +50,44 @@ describe("prompt reads exclude tombstoned rows", () => { expect(intakeRead).not.toBeNull(); expect(intakeRead![0]).toContain("deletedAt: null"); }); + // A soft delete has to be able to reach the CACHE GATE as well as the + // prompt. `buildStatusInputHash` fingerprints "how much data is there and + // how new is it"; a tombstoned row that still counts leaves the fingerprint + // unchanged, the gate re-dates yesterday's assessment, and text reasoning + // over a deleted reading is served as today's. The measurement arm of that + // same query already filters, which is what makes the two that did not so + // easy to miss. + it("the status input hash filters its mood aggregate", () => { + const src = read("../status-cache.ts"); + const moodRead = src.match( + /moodEntry\s*\n?\s*\.aggregate\(\{[\s\S]{0,800}?where:\s*\{[^}]*\}/, + ); + expect(moodRead).not.toBeNull(); + expect(moodRead![0]).toContain("deletedAt: null"); + }); + + it("the status input hash filters its custom-metric entry reads", () => { + const src = read("../status-cache.ts"); + // Both the count and the newest-entry probe: either one left unfiltered + // keeps the fingerprint still after a delete. + expect(src).toMatch( + /_count:\s*\{\s*select:\s*\{\s*entries:\s*\{[\s\S]{0,200}?deletedAt:\s*null/, + ); + const newest = src.match( + /entries:\s*\{\s*where:\s*\{[^}]*\}[\s\S]{0,200}?take:\s*1/, + ); + expect(newest).not.toBeNull(); + expect(newest![0]).toContain("deletedAt: null"); + }); + + it("the custom-metric correlation channel filters its entry read", () => { + // The parent metric's own tombstone is filtered; its entries' were not, + // so a reading the person deleted still shaped the correlation cards. + const src = read("../correlation-channel-series.ts"); + const entryRead = src.match( + /entries:\s*\{[\s\S]{0,400}?where:\s*\{[^}]*\}/, + ); + expect(entryRead).not.toBeNull(); + expect(entryRead![0]).toContain("deletedAt: null"); + }); }); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 7e1be8512..1c695579e 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -288,7 +288,10 @@ export async function fetchCustomMetricBehaviourSeries( name: true, unit: true, entries: { - where: { measuredAt: { gte: since } }, + // The parent metric's tombstone is filtered above; the entries' own + // one has to be too, or a reading the person deleted keeps shaping + // the correlation they are shown. + where: { deletedAt: null, measuredAt: { gte: since } }, orderBy: [{ measuredAt: "desc" }, { id: "desc" }], take: 2000, select: { value: true, unit: true, measuredAt: true }, diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 6d6408d67..645b46724 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -324,7 +324,12 @@ export async function computeStatusInputFingerprint(args: { }), args.includeMood ? prisma.moodEntry.aggregate({ - where: { userId: args.userId }, + // Tombstoned rows are excluded here for the same reason they are in + // the measurement arm above: this aggregate IS the freshness + // fingerprint, so a deletion that leaves the count and the newest + // timestamp untouched cannot invalidate the cached assessment, and + // yesterday's text gets re-dated as today's over data that is gone. + where: { userId: args.userId, deletedAt: null }, _count: { _all: true }, _max: { moodLoggedAt: true }, }) @@ -342,8 +347,9 @@ export async function computeStatusInputFingerprint(args: { id: true, unit: true, updatedAt: true, - _count: { select: { entries: true } }, + _count: { select: { entries: { where: { deletedAt: null } } } }, entries: { + where: { deletedAt: null }, orderBy: [{ measuredAt: "desc" }, { id: "desc" }], take: 1, select: { measuredAt: true, unit: true },