fix(insights): keep deleted mood entries and readings out of status snapshots

Four per-metric status snapshots read mood context without the tombstone
filter, and the blood-pressure snapshot read measurements the same way, so
a deleted entry kept shaping the text written about it. The breathing
screen and the HRV widget probe had the same gap.

One reader now serves the shared mood context, and a guard derives the
soft-deleting models from the schema and fails on any unfiltered read in
the analysis, insight and model-input directories.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 09:15:31 +02:00
1 parent fea1d1e0b5
commit 67e2563c63
10 files changed
+254 -33

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
/**
* Structural guard: a read that feeds analysis or a model prompt never sees a
* deleted row.
*
* Several record types delete softly: the row stays with `deletedAt` set so
* sync can propagate the delete and undo can bring it back. Four per-metric
* status snapshots read mood entries with `where: { userId }` alone, so an
* entry the person had deleted kept shaping the text written about them, and
* the blood-pressure snapshot did the same with measurements.
*
* The models are derived from the schema (every model with a `deletedAt`
* column), not listed by hand, so a model that gains soft delete is covered
* the day it does. The scope is every directory whose output is analysis,
* a score, an insight, or model input. A read passes when its argument names
* `deletedAt`, or when it filters through a local object that does. The
* entries below read tombstones on purpose and say why. Comments are
* stripped first; the planted cases prove the matcher fires.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const SCOPES = [
"lib/insights/",
"lib/analytics/",
"lib/ai/",
"lib/rollups/",
"lib/doctor-report/",
"lib/day/",
"lib/timeline/",
"lib/targets/",
"lib/gamification/",
"lib/mood/",
"app/api/insights/",
"app/api/analytics/",
"app/api/dashboard/",
];
/** `model@file` → why this read includes deleted rows. */
const ALLOWED: Record<string, string> = {
"measurement@lib/rollups/measurement-rollups.ts":
"Freshness probe on updatedAt: a delete bumps it and must invalidate the rollup.",
"moodEntry@lib/rollups/mood-rollups.ts":
"Freshness probe on updatedAt: a delete bumps it and must invalidate the rollup.",
"coachFact@lib/ai/coach/memory/trail-read.ts":
"Withholds medication texts from the recall trail; a deleted fact still withholds, which errs toward saying less.",
"coachFact@lib/ai/coach/memory/remember.ts":
"Answer-once check on the message that proposed the fact, inside its lock.",
};
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
function tombstonedModels(): string[] {
const schema = readFileSync(
join(process.cwd(), "prisma/schema.prisma"),
"utf8",
);
const models = new Set<string>();
let current: string | null = null;
for (const line of schema.split("\n")) {
const head = /^model\s+(\w+)\s*\{/.exec(line);
if (head) current = head[1];
else if (/^\}/.test(line)) current = null;
else if (current && /^\s+deletedAt\s+DateTime\?/.test(line)) {
models.add(current[0].toLowerCase() + current.slice(1));
}
}
return [...models].sort();
}
/** The local object a read filters through carries `deletedAt`. */
function filtersThroughLiveObject(
code: string,
arg: string,
at: number,
): boolean {
const names = [...arg.matchAll(/(?:where:\s*|\.\.\.)(\w+)/g)].map(
(m) => m[1],
);
if (/(?:^|[{,])\s*where\s*[,}]/.test(arg)) names.push("where");
return names.some((name) => {
// The nearest declaration above the read is the one it uses.
const decl = new RegExp(
`(?:const|let)\\s+${name}\\b[^=]*=\\s*\\{([\\s\\S]{0,600}?)\\};`,
"g",
);
const before = [...code.slice(0, at).matchAll(decl)].at(-1);
return before !== undefined && /\bdeletedAt\b/.test(before[1]);
});
}
export function unfilteredReads(
code: string,
models: string[],
): { model: string; arg: string }[] {
const re = new RegExp(
`\\.(${models.join("|")})\\s*\\.\\s*(findMany|findFirst|count|groupBy|aggregate)\\s*\\(`,
"g",
);
const out: { model: string; arg: string }[] = [];
for (const m of code.matchAll(re)) {
let i = m.index + m[0].length;
let depth = 1;
while (depth > 0 && i < code.length) {
if (code[i] === "(") depth += 1;
else if (code[i] === ")") depth -= 1;
i += 1;
}
const arg = code.slice(m.index + m[0].length, i - 1);
if (/\bdeletedAt\b/.test(arg)) continue;
if (filtersThroughLiveObject(code, arg, m.index)) continue;
out.push({ model: m[1], arg: arg.replace(/\s+/g, " ").slice(0, 120) });
}
return out;
}
describe("analysis reads skip deleted rows", () => {
const models = tombstonedModels();
it("derives the soft-deleting models from the schema", () => {
expect(models.length).toBeGreaterThanOrEqual(20);
expect(models).toContain("moodEntry");
expect(models).toContain("measurement");
});
it("the matcher fires on an unfiltered read and passes a filtered one", () => {
const planted = `const rows = await prisma.moodEntry
.findMany({ where: { userId }, take: 90 });`;
expect(unfilteredReads(planted, models)).toHaveLength(1);
expect(
unfilteredReads(
"await prisma.moodEntry.findMany({ where: { userId, deletedAt: null } });",
models,
),
).toEqual([]);
const viaLocal = `const where = { userId, deletedAt: null };
await prisma.measurement.count({ where });`;
expect(unfilteredReads(viaLocal, models)).toEqual([]);
const viaLocalWithout = `const where = { userId };
await prisma.measurement.count({ where });`;
expect(unfilteredReads(viaLocalWithout, models)).toHaveLength(1);
});
it("no read in scope includes deleted rows outside the list", () => {
const files = walkSourceFiles(SRC, { floor: 3000 }).filter(
(file) =>
SCOPES.some((scope) => file.startsWith(scope)) &&
!file.includes("__tests__/") &&
!/\.test\.tsx?$/.test(file),
);
expect(files.length).toBeGreaterThan(400);
let reads = 0;
const readRe = new RegExp(`\\.(${models.join("|")})\\s*\\.\\s*find`, "g");
const offenders: string[] = [];
for (const file of files) {
const code = stripComments(readFileSync(join(SRC, file), "utf8"));
reads += [...code.matchAll(readRe)].length;
for (const hit of unfilteredReads(code, models)) {
const key = `${hit.model}@${file}`;
if (!(key in ALLOWED)) offenders.push(`${key}: ${hit.arg}`);
}
}
// The scope reads these models in well over a hundred places; a sweep
// that finds a handful read the wrong tree.
expect(reads).toBeGreaterThan(100);
expect(offenders).toEqual([]);
});
});
+5 -1
View File
@@ -245,7 +245,11 @@ export const GET = apiHandler(async () => {
let hasSdnn = true;
if (!recoveryEnabled) {
const sdnnRow = await prisma.measurement.findFirst({
where: { userId: user.id, type: "HEART_RATE_VARIABILITY" },
where: {
userId: user.id,
type: "HEART_RATE_VARIABILITY",
deletedAt: null,
},
select: { id: true },
});
hasSdnn = sdnnRow !== null;
@@ -48,6 +48,7 @@ export const GET = apiHandler(async () => {
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] },
measuredAt: { gte: since },
},
+3 -8
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getBloodPressureSystemPrompt,
getBloodPressureUserPrompt,
@@ -238,6 +239,7 @@ export async function prepareBloodPressureStatusForUser(
.findMany({
where: {
userId,
deletedAt: null,
type: {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
},
@@ -457,14 +459,7 @@ export async function prepareBloodPressureStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries (~3 months) so power users don't pull
// unbounded rows before the bucket-series budget runs.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodEntries = await readRecentMoodScores(userId, 90);
const moodSeries = applyPayloadBudget(
moodEntries.map((entry) => ({
+2 -8
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getGeneralStatusSystemPrompt,
getGeneralStatusUserPrompt,
@@ -309,14 +310,7 @@ export async function prepareGeneralStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodEntries = await readRecentMoodScores(userId, 90);
const moodRecords = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
+1
View File
@@ -241,6 +241,7 @@ export async function prepareMoodStatusForUser(
.findMany({
where: {
userId,
deletedAt: null,
},
orderBy: { date: "desc" },
take: 90,
+2 -8
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getPulseSystemPrompt,
getPulseUserPrompt,
@@ -252,14 +253,7 @@ export async function preparePulseStatusForUser(
// Fetch mood context (optional — for enrichment only). Cap at 90
// entries (~3 months); the bucket-series budget then summarises
// further. v1.4.28 FB-D2 — prevent unbounded reads for power users.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodEntries = await readRecentMoodScores(userId, 90);
const moodPoints = moodEntries.map((entry) => ({
measuredAt: entry.moodLoggedAt,
+2 -8
View File
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
import { readRecentMoodScores } from "@/lib/mood/recent-scores";
import {
getWeightSystemPrompt,
getWeightUserPrompt,
@@ -356,14 +357,7 @@ export async function prepareWeightStatusForUser(
// Fetch mood context (optional — for enrichment only). v1.4.28
// Cap at 90 entries.
const moodEntries = await prisma.moodEntry
.findMany({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
take: 90,
select: { date: true, score: true, moodLoggedAt: true },
})
.then((rows) => rows.reverse());
const moodEntries = await readRecentMoodScores(userId, 90);
const moodSeries = applyPayloadBudget(
moodEntries.map((entry) => ({
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from "vitest";
const { moodEntry } = vi.hoisted(() => ({
moodEntry: { findMany: vi.fn() },
}));
vi.mock("@/lib/db", () => ({ prisma: { moodEntry } }));
import { readRecentMoodScores } from "../recent-scores";
describe("readRecentMoodScores", () => {
it("reads live entries only, newest first, and hands them back oldest first", async () => {
const newer = {
date: "2026-10-02",
score: 4,
moodLoggedAt: new Date("2026-10-02T08:00:00Z"),
};
const older = {
date: "2026-10-01",
score: 2,
moodLoggedAt: new Date("2026-10-01T08:00:00Z"),
};
moodEntry.findMany.mockResolvedValueOnce([newer, older]);
const rows = await readRecentMoodScores("u1", 30);
expect(moodEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "u1", deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
take: 30,
}),
);
expect(rows).toEqual([older, newer]);
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* The recent-mood context read shared by the per-metric status snapshots
* (pulse, weight, blood pressure, general). A deleted entry is a tombstone
* kept for sync and undo; it must never reach a model prompt or a summary,
* so the filter lives here rather than at each call site.
*/
import { prisma } from "@/lib/db";
export interface RecentMoodScore {
date: string;
score: number;
moodLoggedAt: Date;
}
/** Most recent `take` live entries, returned oldest first. */
export async function readRecentMoodScores(
userId: string,
take = 90,
): Promise<RecentMoodScore[]> {
const rows = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
take,
select: { date: true, score: true, moodLoggedAt: true },
});
return rows.reverse();
}