mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
fea1d1e0b5
commit
67e2563c63
10 files changed
+254
-33
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -241,6 +241,7 @@ export async function prepareMoodStatusForUser(
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { date: "desc" },
|
||||
take: 90,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user