mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge fix/guarded-twins
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
ee4afc7b9e
33 files changed
+1164
-73
No files matched your search
@@ -14,6 +14,51 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mood correlations no longer call five days of noise a strong finding.** The
|
||||
mood page painted a "Strong" or "Moderate" badge on a mood-versus-sleep,
|
||||
activity, pulse, weight or blood-pressure card as soon as five days had been
|
||||
paired, with the day count and the coefficient tucked behind an info icon.
|
||||
Five points sloping the right way by chance is not a finding, and the rest of
|
||||
the app has refused to call it one for a while: the weight, blood-pressure
|
||||
and mood status surfaces all require at least twenty paired days and a result
|
||||
that a significance test says is unlikely to be chance. The mood page now
|
||||
requires the same. Fewer cards will show a coefficient, and the ones that do
|
||||
mean something. A card that cannot show one says which of the two it is: how
|
||||
many of the twenty days you have so far, or that twenty days went by and
|
||||
nothing stood out. Those used to look identical.
|
||||
- **A medication with no schedule no longer reports perfect adherence.** If you
|
||||
saved a medication without a dose time, the insights list, the blood-pressure
|
||||
read and the medication-compliance summary all showed it at 100 percent and
|
||||
folded that into your average, because nothing was expected of it so nothing
|
||||
could be missed. It was never an adherence figure, and it made every other
|
||||
medication's average look better than it was. Such a medication is now left
|
||||
out of adherence entirely, which is what the doctor report has always done.
|
||||
Add a schedule and it comes back.
|
||||
- **A day you never logged is no longer counted as a day you took nothing.** The
|
||||
chart pairing blood-pressure medication continuity against your systolic
|
||||
readings treated every day without an intake record as a measured zero, so a
|
||||
week away from the app read as a week off your medication and dragged the
|
||||
relationship between the two around. Only days that carry an intake record
|
||||
are used now. A dose you marked as skipped is still a real zero and still
|
||||
counts; a day you simply did not open the app is not in the chart at all.
|
||||
- **A lab note this instance cannot decrypt survives an export instead of
|
||||
vanishing.** If your encryption key was rotated and the old one dropped
|
||||
before every row had moved, a lab note or a biomarker context written under
|
||||
the old key came out of a data export as empty. In the file that is
|
||||
indistinguishable from a note you never wrote, so restoring the export
|
||||
finished the job and the text was gone with nothing to show it had been
|
||||
there. Both now export as a short marker saying the text was encrypted with
|
||||
a key this instance no longer holds, and the marker survives a restore. The
|
||||
original text is still unrecoverable without the old key; what changes is
|
||||
that you can see something was lost.
|
||||
- **A deleted mood entry or custom-metric reading now counts as deleted in the
|
||||
AI surfaces.** Deleting a reading left it in place with a tombstone, and
|
||||
three reads had not been told to skip tombstones: the correlation channel
|
||||
built from a custom metric kept using deleted readings, and the check that
|
||||
decides whether an AI assessment is still current could not see a mood
|
||||
deletion or a custom-metric deletion at all, so yesterday's text was re-dated
|
||||
and served as today's over data you had removed. All three now skip deleted
|
||||
rows.
|
||||
- **Twenty-eight settings a self-hoster could write down but not actually
|
||||
change.** The `environment:` block in `docker-compose.yml` is a whitelist:
|
||||
compose reads `.env` for `${VAR}` substitution, so a value set there looks
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} gepaarte Tage · r = {r}",
|
||||
"emptyTitle": "Noch nicht genug gepaarte Daten",
|
||||
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.",
|
||||
"pendingDescription": "{n} von {needed} gepaarten Tagen. Ein Zusammenhang erscheint, sobald genug Tage da sind, um Muster von Zufall zu unterscheiden.",
|
||||
"noLinkTitle": "Kein klarer Zusammenhang",
|
||||
"noLinkDescription": "{n} gepaarte Tage, und nichts hebt sich vom Zufall ab.",
|
||||
"weightTitle": "Stimmung vs. Gewicht",
|
||||
"bloodPressureTitle": "Stimmung vs. Blutdruck",
|
||||
"weightAxis": "Gewicht",
|
||||
|
||||
@@ -3708,6 +3708,9 @@
|
||||
"sourceLabel": "How this was computed",
|
||||
"emptyTitle": "Not enough paired data yet",
|
||||
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
|
||||
"pendingDescription": "{n} of {needed} paired days so far. A correlation appears once there are enough days to tell a pattern from chance.",
|
||||
"noLinkTitle": "No clear link",
|
||||
"noLinkDescription": "{n} paired days, and nothing stands out beyond what chance would produce.",
|
||||
"weightTitle": "Mood vs. weight",
|
||||
"bloodPressureTitle": "Mood vs. blood pressure",
|
||||
"weightAxis": "Weight",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} días emparejados · r = {r}",
|
||||
"emptyTitle": "Aún no hay suficientes datos emparejados",
|
||||
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.",
|
||||
"pendingDescription": "{n} de {needed} días emparejados. La correlación aparece cuando hay días suficientes para distinguir un patrón del azar.",
|
||||
"noLinkTitle": "Sin relación clara",
|
||||
"noLinkDescription": "{n} días emparejados, y nada destaca más allá de lo que produciría el azar.",
|
||||
"weightTitle": "Ánimo vs. peso",
|
||||
"bloodPressureTitle": "Ánimo vs. presión arterial",
|
||||
"weightAxis": "Peso",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} jours appariés · r = {r}",
|
||||
"emptyTitle": "Pas encore assez de données appariées",
|
||||
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.",
|
||||
"pendingDescription": "{n} jours appariés sur {needed}. Une corrélation apparaît dès qu'il y a assez de jours pour distinguer un motif du hasard.",
|
||||
"noLinkTitle": "Aucun lien net",
|
||||
"noLinkDescription": "{n} jours appariés, et rien ne se démarque de ce que le hasard produirait.",
|
||||
"weightTitle": "Humeur vs. poids",
|
||||
"bloodPressureTitle": "Humeur vs. tension artérielle",
|
||||
"weightAxis": "Poids",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} giorni appaiati · r = {r}",
|
||||
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
|
||||
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.",
|
||||
"pendingDescription": "{n} di {needed} giorni appaiati. La correlazione compare quando i giorni bastano a distinguere un pattern dal caso.",
|
||||
"noLinkTitle": "Nessun legame chiaro",
|
||||
"noLinkDescription": "{n} giorni appaiati, e nulla emerge oltre a quanto produrrebbe il caso.",
|
||||
"weightTitle": "Umore vs. peso",
|
||||
"bloodPressureTitle": "Umore vs. pressione sanguigna",
|
||||
"weightAxis": "Peso",
|
||||
|
||||
@@ -3708,6 +3708,9 @@
|
||||
"sourceLabel": "계산 방법",
|
||||
"emptyTitle": "아직 짝지을 데이터가 부족해요",
|
||||
"emptyDescription": "같은 날에 기분과 이 지표를 함께 기록하면 상관관계를 볼 수 있어요.",
|
||||
"pendingDescription": "짝지은 {n}일 / {needed}일. 우연과 패턴을 구분할 만큼 날이 쌓이면 상관관계가 나타나요.",
|
||||
"noLinkTitle": "뚜렷한 관계 없음",
|
||||
"noLinkDescription": "짝지은 {n}일 동안 우연으로 볼 수 있는 범위를 넘는 건 없었어요.",
|
||||
"weightTitle": "기분 대 체중",
|
||||
"bloodPressureTitle": "기분 대 혈압",
|
||||
"weightAxis": "체중",
|
||||
|
||||
@@ -3707,6 +3707,9 @@
|
||||
"source": "{n} sparowanych dni · r = {r}",
|
||||
"emptyTitle": "Za mało sparowanych danych",
|
||||
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.",
|
||||
"pendingDescription": "{n} z {needed} sparowanych dni. Korelacja pojawi się, gdy dni wystarczy, aby odróżnić wzorzec od przypadku.",
|
||||
"noLinkTitle": "Brak wyraźnego związku",
|
||||
"noLinkDescription": "{n} sparowanych dni i nic nie wykracza poza to, co dałby przypadek.",
|
||||
"weightTitle": "Nastrój vs. waga",
|
||||
"bloodPressureTitle": "Nastrój vs. ciśnienie krwi",
|
||||
"weightAxis": "Waga",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Structural guard on the unguarded Pearson helper.
|
||||
*
|
||||
* `pearsonCorrelation` bands a coefficient from |r| alone above a floor of
|
||||
* five pairs, with no significance test. Its guarded twin
|
||||
* `significantPearsonCorrelation` sits directly beneath it in the same file
|
||||
* and exists because of exactly one failure: five days of noise sloping the
|
||||
* right way reaching a reader labelled "strong". The comment above the twin
|
||||
* says so in as many words.
|
||||
*
|
||||
* The twin was written once and adopted one surface at a time, which is how
|
||||
* the mood page kept the naive call for four minor lines after the surfaces
|
||||
* either side of it had moved. Nothing failed when it was skipped, because
|
||||
* nothing was watching. This is what watches: a new caller of the unguarded
|
||||
* helper has to be added to the list below, in a diff a reviewer reads, next
|
||||
* to a written reason.
|
||||
*
|
||||
* It is a tripwire, not a proof. It cannot say the remaining entries are
|
||||
* right — only that the set has not grown without someone editing this file.
|
||||
* The two entries it carries today are named as open work, not as blessings.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/**
|
||||
* Files that may still call `pearsonCorrelation` directly, and why.
|
||||
*
|
||||
* Neither entry is settled. Both are the same migration the user-facing
|
||||
* surfaces already made, still owed by two payload builders whose consumers
|
||||
* pin the n >= 5 shape; moving them changes what those payloads contain and
|
||||
* belongs in its own change with its own tests, not smuggled into this one.
|
||||
*/
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"app/api/insights/comprehensive/route.ts":
|
||||
"Five correlations on the /insights envelope (weight x BP, mood x BP, " +
|
||||
"mood x weight, mood x pulse, BP-medication continuity). Open: they " +
|
||||
"carry the same small-n exposure the mood page had, and the page's " +
|
||||
"cards read `n` straight off the envelope.",
|
||||
"lib/insights/features.ts":
|
||||
"Eight cross-metric coefficients on the AI feature payload. Open: the " +
|
||||
"prompt reads them as findings, so the same significance bar applies; " +
|
||||
"the snapshot shape and its budget tests move with it.",
|
||||
};
|
||||
|
||||
/** Every non-test, non-generated source file under `src/`. */
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* A call to the unguarded helper. Whitespace-tolerant and anchored on a word
|
||||
* boundary so `significantPearsonCorrelation(` cannot match: a matcher that
|
||||
* quietly matches nothing is the failure mode this repository has shipped
|
||||
* before, so the count is asserted below as well.
|
||||
*/
|
||||
const CALL = /(?<![A-Za-z])pearsonCorrelation\s*\(/;
|
||||
|
||||
describe("the unguarded Pearson helper has a closed caller set", () => {
|
||||
it("finds callers at all, so an empty result cannot read as a pass", () => {
|
||||
const definition = read("lib/analytics/correlations.ts");
|
||||
expect(definition).toMatch(/export function pearsonCorrelation\s*\(/);
|
||||
expect(
|
||||
definition,
|
||||
"the guarded twin must exist for this guard to mean anything",
|
||||
).toMatch(/export function significantPearsonCorrelation\s*\(/);
|
||||
|
||||
const callers = sourceFiles().filter(
|
||||
(rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
);
|
||||
expect(callers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("names every caller outside the allowlist", () => {
|
||||
const callers = sourceFiles().filter(
|
||||
(rel) => rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
);
|
||||
|
||||
expect(
|
||||
callers.filter((rel) => !(rel in ALLOWED)),
|
||||
"A new caller of the unguarded `pearsonCorrelation`. A surface a " +
|
||||
"person reads must use `significantPearsonCorrelation` (n >= 20 " +
|
||||
"AND p < 0.05). If this one genuinely cannot, add it to ALLOWED " +
|
||||
"in this file with the reason, so the exception is reviewed rather " +
|
||||
"than assumed.",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the allowlist honest by refusing stale entries", () => {
|
||||
const callers = new Set(
|
||||
sourceFiles().filter(
|
||||
(rel) =>
|
||||
rel !== "lib/analytics/correlations.ts" && CALL.test(read(rel)),
|
||||
),
|
||||
);
|
||||
|
||||
// An entry whose file no longer calls the helper is a licence nobody
|
||||
// needs any more; leaving it standing is how the next one gets waved in.
|
||||
expect(
|
||||
Object.keys(ALLOWED).filter((rel) => !callers.has(rel)),
|
||||
"this file is on the allowlist but no longer calls the unguarded " +
|
||||
"helper — drop the entry",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -776,15 +776,20 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
// Intake taken on the first three days, missed on the last two → a
|
||||
// non-degenerate continuity series.
|
||||
const intakeEvents = days.slice(0, 3).map((d) => ({
|
||||
// Intake taken on the first three days, logged as skipped on the last
|
||||
// two → a non-degenerate continuity series. The skips used to be absent
|
||||
// rows: a day with no record read as a measured 0 % continuity, which is
|
||||
// the fabrication the route no longer makes, and a fixture that relies on
|
||||
// it would be pinning the defect rather than the timezone question this
|
||||
// test asks. Recording the two days as skips keeps five paired days AND
|
||||
// keeps every one of them a thing the person actually logged.
|
||||
const intakeEvents = days.map((d, i) => ({
|
||||
medicationId: "med-bp",
|
||||
userId: "user-comp-1",
|
||||
deletedAt: null,
|
||||
scheduledFor: new Date(`${d}T07:00:00Z`),
|
||||
takenAt: new Date(`${d}T07:05:00Z`),
|
||||
skipped: false,
|
||||
takenAt: i < 3 ? new Date(`${d}T07:05:00Z`) : null,
|
||||
skipped: i >= 3,
|
||||
}));
|
||||
|
||||
function primeMocks() {
|
||||
@@ -827,3 +832,227 @@ describe("GET /api/insights/comprehensive — mood × metric local-day pairing (
|
||||
expect(utcBody.data.bpMedicationCorrelation).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, so it has no adherence to
|
||||
// report. `calculateCompliance` answers `rate: 100, totalExpected: 0` for that
|
||||
// case — arithmetically true and a lie on a surface that reads the number as
|
||||
// adherence. The doctor report has excluded both the PRN arm and the empty
|
||||
// schedule since it was written, with the reason beside it; this envelope
|
||||
// filtered only PRN, so a scheduled medication saved without a schedule (which
|
||||
// `POST /api/medications` accepts) reported perfect adherence to the reader and
|
||||
// to the prompt built from the same array.
|
||||
describe("GET /api/insights/comprehensive — no adherence without an expectation", () => {
|
||||
const EMPTY_AGGREGATE = {
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {},
|
||||
firstMeasurementAt: null,
|
||||
totalMeasurements: 0,
|
||||
};
|
||||
|
||||
type MedBody = {
|
||||
data: {
|
||||
medications: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
compliance7: number;
|
||||
compliance30: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function scheduledMed(id: string, name: string, schedules: unknown[]) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
schedules,
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
const SCHEDULE = {
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
};
|
||||
|
||||
it("leaves a scheduled medication with no schedule out of the compliance list", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
EMPTY_AGGREGATE,
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
scheduledMed("med-none", "Unscheduled", []),
|
||||
scheduledMed("med-real", "Amlodipine", [SCHEDULE]),
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as MedBody;
|
||||
|
||||
const names = body.data.medications.map((m) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
// The medication that DOES expect a dose still reports, so this is a
|
||||
// filter and not a blanket suppression.
|
||||
expect(names).toContain("Amlodipine");
|
||||
});
|
||||
|
||||
it("never reports 100 % for a medication with nothing expected of it", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
EMPTY_AGGREGATE,
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
scheduledMed("med-none", "Unscheduled", []),
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as MedBody;
|
||||
|
||||
expect(body.data.medications).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// The medication-continuity correlation joined every day that had a systolic
|
||||
// reading against `takenByDay.get(day) ?? 0`. A day the reader simply never
|
||||
// logged an intake for therefore entered the scatter and the coefficient as a
|
||||
// measured 0 % continuity. Absence is not zero anywhere else in this project
|
||||
// and it is not zero here: a day with a logged skip is a real zero and stays,
|
||||
// a day with no record at all drops out.
|
||||
describe("GET /api/insights/comprehensive — an unlogged day is not a zero", () => {
|
||||
type BpBody = {
|
||||
data: {
|
||||
bpMedicationScatterData: Array<{ continuityPct: number; sysBP: number }>;
|
||||
bpMedicationCorrelation: { n: number } | null;
|
||||
};
|
||||
};
|
||||
|
||||
const DAYS = [
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
"2026-05-05",
|
||||
];
|
||||
|
||||
const MEDICATION = {
|
||||
id: "med-bp",
|
||||
name: "Amlodipine",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date("2026-04-01T00:00:00Z"),
|
||||
schedules: [
|
||||
{
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
},
|
||||
],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
|
||||
function primeBp(
|
||||
events: Array<{ day: string; takenAt: Date | null; skipped: boolean }>,
|
||||
) {
|
||||
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
{
|
||||
summaries: {},
|
||||
bpRawRows: { sys: [], dia: [] },
|
||||
weightRawRows: [],
|
||||
dailyByType: {
|
||||
BLOOD_PRESSURE_SYS: DAYS.map((d, i) => ({
|
||||
day: d,
|
||||
value: 120 + i * 2,
|
||||
})),
|
||||
},
|
||||
firstMeasurementAt: new Date(),
|
||||
totalMeasurements: 10,
|
||||
},
|
||||
);
|
||||
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
MEDICATION,
|
||||
]);
|
||||
(
|
||||
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(
|
||||
events.map((e) => ({
|
||||
medicationId: "med-bp",
|
||||
userId: "user-comp-1",
|
||||
deletedAt: null,
|
||||
scheduledFor: new Date(`${e.day}T07:00:00Z`),
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
})),
|
||||
);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-bp": "BLOOD_PRESSURE",
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps only the days that carry an intake record", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Three days logged; the last two were never touched.
|
||||
primeBp(
|
||||
DAYS.slice(0, 3).map((day) => ({
|
||||
day,
|
||||
takenAt: new Date(`${day}T07:05:00Z`),
|
||||
skipped: false,
|
||||
})),
|
||||
);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as BpBody;
|
||||
|
||||
expect(body.data.bpMedicationScatterData).toHaveLength(3);
|
||||
// The two unlogged days would have entered as 0 % against their own
|
||||
// systolic reading; both are gone.
|
||||
expect(body.data.bpMedicationScatterData.map((p) => p.sysBP)).not.toContain(
|
||||
126,
|
||||
);
|
||||
expect(
|
||||
body.data.bpMedicationScatterData.every((p) => p.continuityPct === 100),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a logged skip, which is a real zero", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeBp([
|
||||
...DAYS.slice(0, 3).map((day) => ({
|
||||
day,
|
||||
takenAt: new Date(`${day}T07:05:00Z`),
|
||||
skipped: false,
|
||||
})),
|
||||
{ day: DAYS[3], takenAt: null, skipped: true },
|
||||
]);
|
||||
|
||||
const body = (await (await callGet(makeReq())).json()) as BpBody;
|
||||
|
||||
expect(body.data.bpMedicationScatterData).toHaveLength(4);
|
||||
expect(body.data.bpMedicationScatterData).toContainEqual({
|
||||
continuityPct: 0,
|
||||
sysBP: 126,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -365,7 +366,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
|
||||
// ── Medication compliance ────────────────────────────────
|
||||
// Bounded reads — left untouched per the directive.
|
||||
const medications = await prisma.medication.findMany({
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -379,6 +380,12 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
pauseEras: { select: { pausedAt: true, resumedAt: true } },
|
||||
},
|
||||
});
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
medications.map((m) => m.id),
|
||||
);
|
||||
@@ -497,14 +504,23 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
|
||||
}
|
||||
|
||||
const takenByDay = new Map<string, number>();
|
||||
// Days that carry an intake record of ANY kind. A logged skip is a real
|
||||
// zero and belongs in the series; a day the person never touched is not a
|
||||
// zero, it is nothing, and pairing it against that day's systolic reading
|
||||
// invented a data point they never produced. Absence is not zero, here as
|
||||
// everywhere else in this project, so the unlogged day drops out of both
|
||||
// the scatter and the coefficient rather than dragging continuity down.
|
||||
const loggedDays = new Set<string>();
|
||||
for (const event of bpMedicationEvents) {
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
const dayKey = event.scheduledFor.toISOString().slice(0, 10);
|
||||
loggedDays.add(dayKey);
|
||||
if (event.skipped || !event.takenAt) continue;
|
||||
takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const pairs: Array<{ a: number; b: number; date: Date }> = [];
|
||||
for (const [dayKey, avgSys] of sysByDay.entries()) {
|
||||
if (!loggedDays.has(dayKey)) continue;
|
||||
const taken = takenByDay.get(dayKey) ?? 0;
|
||||
const continuity = Math.min(1, taken / expectedBpIntakesPerDay);
|
||||
pairs.push({
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* What the mood correlation card is allowed to claim.
|
||||
*
|
||||
* The card used to paint a "Strong" badge from any coefficient it was handed,
|
||||
* as long as five days had been paired. The coefficient itself now clears the
|
||||
* significance bar before it leaves the server, so the card's job here is the
|
||||
* other half: when the bar refuses, say which bar and how far off the reader
|
||||
* is, rather than falling back to the same "not enough paired data" blank that
|
||||
* a reader with no data at all sees.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
MoodCorrelationCards,
|
||||
type MoodMetricCorrelationData,
|
||||
} from "../mood-correlation-cards";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const NOTHING: MoodMetricCorrelationData = {
|
||||
result: null,
|
||||
points: [],
|
||||
n: 0,
|
||||
suppressed: "insufficientPairs",
|
||||
};
|
||||
|
||||
/** The sleep card's own markup — the four siblings carry their own copy. */
|
||||
function renderSleep(sleep: MoodMetricCorrelationData) {
|
||||
const html = render(
|
||||
<MoodCorrelationCards
|
||||
sleep={sleep}
|
||||
steps={NOTHING}
|
||||
pulse={NOTHING}
|
||||
weight={NOTHING}
|
||||
bloodPressureSystolic={NOTHING}
|
||||
/>,
|
||||
);
|
||||
const start = html.indexOf('data-kind="sleep"');
|
||||
const end = html.indexOf('data-kind="steps"');
|
||||
return html.slice(start, end);
|
||||
}
|
||||
|
||||
describe("<MoodCorrelationCards>", () => {
|
||||
it("shows no strength badge for a refused correlation", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 5,
|
||||
suppressed: "insufficientPairs",
|
||||
});
|
||||
|
||||
expect(html).not.toContain("Strong");
|
||||
expect(html).not.toContain("Moderate");
|
||||
});
|
||||
|
||||
it("names how many paired days are still missing instead of a bare blank", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 5 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 5,
|
||||
suppressed: "insufficientPairs",
|
||||
});
|
||||
|
||||
// The reader is told where they stand, not only that something is absent.
|
||||
expect(html).toContain("5 of 20");
|
||||
});
|
||||
|
||||
it("keeps the plain empty copy when nothing has been paired at all", () => {
|
||||
const html = renderSleep(NOTHING);
|
||||
|
||||
expect(html).toContain("Not enough paired data yet");
|
||||
expect(html).not.toContain("0 of 20");
|
||||
});
|
||||
|
||||
it("says a twenty-day pairing showed no reliable link", () => {
|
||||
const html = renderSleep({
|
||||
result: null,
|
||||
points: Array.from({ length: 20 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 20,
|
||||
suppressed: "notSignificant",
|
||||
});
|
||||
|
||||
expect(html).toContain("No clear link");
|
||||
expect(html).not.toContain("Not enough paired data yet");
|
||||
expect(html).not.toContain("Weak");
|
||||
});
|
||||
|
||||
it("still shows the badge and the n · r caption for a correlation that cleared the bar", () => {
|
||||
const html = renderSleep({
|
||||
result: { r: 0.82, strength: "stark", n: 24 },
|
||||
points: Array.from({ length: 24 }, (_, i) => ({ x: i, y: i })),
|
||||
n: 24,
|
||||
});
|
||||
|
||||
expect(html).toContain("Strong");
|
||||
expect(html).toContain("24 paired days");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,9 @@ import { MoodExplainerIcon } from "./mood-explainer-icon";
|
||||
* pulse) pair. The Pearson math comes pre-computed from
|
||||
* `/api/mood/insights` (single source of truth with the LLM snapshot).
|
||||
* Below-threshold pairs render an EmptyState rather than a misleading
|
||||
* near-zero scatter.
|
||||
* near-zero scatter — and the EmptyState names which threshold, because a
|
||||
* pairing that is still building and one that found nothing are different
|
||||
* answers and used to read as the same blank.
|
||||
*/
|
||||
|
||||
const ScatterCorrelationChart = dynamic(
|
||||
@@ -46,8 +48,17 @@ export interface MoodMetricCorrelationData {
|
||||
result: CorrelationResult | null;
|
||||
points: MoodCorrelationPoint[];
|
||||
n: number;
|
||||
/** Which bar the pairing fell short of, when there is no coefficient. */
|
||||
suppressed?: "insufficientPairs" | "notSignificant";
|
||||
}
|
||||
|
||||
/**
|
||||
* The paired-day floor the server's significance gate enforces. Repeated
|
||||
* here only to tell the reader how far off they are; the decision itself is
|
||||
* the server's and arrives as `suppressed`.
|
||||
*/
|
||||
const MIN_PAIRED_DAYS = 20;
|
||||
|
||||
type MetricKind = "sleep" | "steps" | "pulse" | "weight" | "bloodPressure";
|
||||
|
||||
const TITLE_KEY: Record<MetricKind, string> = {
|
||||
@@ -95,7 +106,33 @@ function MoodCorrelationCard({
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const hasResult = data.result != null && data.n >= 5;
|
||||
const hasResult = data.result != null;
|
||||
|
||||
// Three different silences, and the reader deserves to know which one they
|
||||
// are looking at: nothing paired yet, some days paired but not enough to
|
||||
// separate a pattern from chance, or enough days and no pattern in them.
|
||||
// The last one is a finding, not a gap, and used to render as the same
|
||||
// "not enough data" blank as the first.
|
||||
const emptyCopy =
|
||||
data.suppressed === "notSignificant"
|
||||
? {
|
||||
title: t("insights.mood.correlation.noLinkTitle"),
|
||||
description: t("insights.mood.correlation.noLinkDescription", {
|
||||
n: data.n,
|
||||
}),
|
||||
}
|
||||
: data.n > 0
|
||||
? {
|
||||
title: t("insights.mood.correlation.emptyTitle"),
|
||||
description: t("insights.mood.correlation.pendingDescription", {
|
||||
n: data.n,
|
||||
needed: MIN_PAIRED_DAYS,
|
||||
}),
|
||||
}
|
||||
: {
|
||||
title: t("insights.mood.correlation.emptyTitle"),
|
||||
description: t("insights.mood.correlation.emptyDescription"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -168,8 +205,8 @@ function MoodCorrelationCard({
|
||||
variant="plain"
|
||||
size="compact"
|
||||
icon={<Activity className="size-5" />}
|
||||
title={t("insights.mood.correlation.emptyTitle")}
|
||||
description={t("insights.mood.correlation.emptyDescription")}
|
||||
title={emptyCopy.title}
|
||||
description={emptyCopy.description}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -243,6 +243,28 @@ export function buildComplianceDisplay(
|
||||
* can be driven deterministically from a caller
|
||||
* that already pinned its own `now`.
|
||||
*/
|
||||
/**
|
||||
* Whether a medication expects any dose at all, and so has an adherence rate
|
||||
* worth showing.
|
||||
*
|
||||
* `calculateCompliance` below answers `rate: 100, totalExpected: 0` for a
|
||||
* medication with no schedule. That is arithmetically honest — nothing was
|
||||
* expected, so nothing was missed — and it is a lie on every surface that
|
||||
* reads the number as adherence. PRN is the obvious arm and every caller
|
||||
* already filtered it; the empty schedule on a NON-PRN medication is the arm
|
||||
* that got through, and it is reachable: `POST /api/medications` accepts
|
||||
* `schedules ?? []` on a scheduled medication. The doctor report has excluded
|
||||
* both arms since it was written, so the report never prints a fabricated
|
||||
* 100 %; the same reason applies to every other surface, so the two arms live
|
||||
* in one predicate rather than being re-derived at each call site.
|
||||
*/
|
||||
export function expectsDoses(medication: {
|
||||
asNeeded: boolean;
|
||||
schedules: readonly unknown[];
|
||||
}): boolean {
|
||||
return !medication.asNeeded && medication.schedules.length > 0;
|
||||
}
|
||||
|
||||
export function calculateCompliance(
|
||||
events: IntakeEvent[],
|
||||
schedules: ComplianceSchedule[],
|
||||
|
||||
@@ -21,6 +21,7 @@ import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve";
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
tallyComplianceFromLedger,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -363,9 +364,9 @@ export function buildLedgerCompliance(
|
||||
const compliance: Record<string, DoctorReportCompliance> = {};
|
||||
for (const med of medications) {
|
||||
// PRN / as-needed carry no schedule and no expected dose — excluded so
|
||||
// the report never prints a fabricated 100 %.
|
||||
if (med.asNeeded) continue;
|
||||
if (med.schedules.length === 0) continue;
|
||||
// the report never prints a fabricated 100 %. Both arms now live in the
|
||||
// shared predicate, which is what the other adherence surfaces read.
|
||||
if (!expectsDoses(med)) continue;
|
||||
|
||||
const events = eventsByMedId.get(med.id) ?? [];
|
||||
const ctx = buildComplianceMedicationContext(
|
||||
|
||||
@@ -19,6 +19,7 @@ import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { encryptNoteToBytes } from "@/lib/labs/store";
|
||||
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
|
||||
import { encryptDocumentSummary } from "@/lib/documents/store";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import {
|
||||
buildRecordsBackupSection,
|
||||
countRecordsBackupSection,
|
||||
@@ -450,10 +451,14 @@ describe("buildRecordsBackupSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails soft (null, not a throw) on an undecryptable note", async () => {
|
||||
it("marks an undecryptable note (not a throw, and not a silent null)", async () => {
|
||||
const prisma = buildMockPrisma();
|
||||
// Corrupt the ciphertext so decrypt throws internally — the row must
|
||||
// still come back with `note: null`, never abort the whole backup.
|
||||
// Corrupt the ciphertext so decrypt throws internally. The row must still
|
||||
// come back and must never abort the whole backup — that part is
|
||||
// unchanged. What it must NOT come back as is `null`, which this test used
|
||||
// to require: in an export file a null note is byte-identical to a note
|
||||
// that was never written, so the restore writes nothing and the loss is
|
||||
// invisible. It comes back as a marker instead.
|
||||
prisma.labResult.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
panel: null,
|
||||
@@ -471,6 +476,6 @@ describe("buildRecordsBackupSection", () => {
|
||||
]);
|
||||
|
||||
const section = await buildRecordsBackupSection(prisma as never, USER_ID);
|
||||
expect(section.labResults[0].note).toBeNull();
|
||||
expect(section.labResults[0].note).toBe(UNREADABLE_EXPORT_MARKER);
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
|
||||
import {
|
||||
recordUnknownKeys,
|
||||
type RestoreSkipLog,
|
||||
@@ -169,7 +170,7 @@ function decryptTurnSoft(bytes: Uint8Array | null): string | null {
|
||||
try {
|
||||
return decryptFromBytes(bytes);
|
||||
} catch {
|
||||
return "[unreadable: encrypted with a key this instance no longer holds]";
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@
|
||||
* verbatim. The DR path never decrypts document content or summaries.
|
||||
*
|
||||
* Every read is scoped to the owner. Soft-deleted rows are excluded so a
|
||||
* restore cannot resurrect tombstones. Other encrypted free-text fields keep
|
||||
* the established fail-soft portable representation and are re-encrypted by
|
||||
* the restore importer.
|
||||
* restore cannot resurrect tombstones. Other encrypted free-text fields are
|
||||
* carried as plaintext and re-encrypted by the restore importer; one this
|
||||
* instance cannot decrypt is carried as a visible marker rather than as
|
||||
* nothing (see `readForExport` below).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { decryptNoteSoft } from "@/lib/labs/store";
|
||||
import { decryptContextSoft } from "@/lib/labs/biomarker-store";
|
||||
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
||||
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "./unreadable-marker";
|
||||
import { decryptDocumentSummary } from "@/lib/documents/store";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
@@ -44,6 +46,39 @@ export const WORKOUTS_MANIFEST_NOTE =
|
||||
"steps) are included. GPS routes and per-sample heart-rate/pace time " +
|
||||
"series are not included in this export.";
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted free-text column for a portable export, or say plainly
|
||||
* that this one could not be read.
|
||||
*
|
||||
* A fail-soft decrypt answers `null` on a wrong or dropped key. In a list view
|
||||
* that is right — one bad row must not take the page down. In an export it is
|
||||
* not: `null` is exactly what a column that was never written looks like, so
|
||||
* the restore importer writes nothing and a note the person did write
|
||||
* disappears without anyone being told. The Coach transcript and the doctor
|
||||
* report both refuse that trade, one with a placeholder sentence and one with
|
||||
* a rendered flag; this follows the same rule.
|
||||
*
|
||||
* The warning the soft helpers emitted is kept, so a systemic key gap still
|
||||
* shows up in the wide event rather than only in the file.
|
||||
*/
|
||||
function readForExport(
|
||||
buf: Uint8Array | null,
|
||||
label: string,
|
||||
decode: (bytes: Uint8Array) => string,
|
||||
): string | null {
|
||||
if (!buf || buf.byteLength === 0) return null;
|
||||
try {
|
||||
return decode(buf);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`${label} decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return UNREADABLE_EXPORT_MARKER;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LabResultBackupEntry {
|
||||
/** Present in canonical DR payloads so rows keep a stable identity. */
|
||||
id?: string;
|
||||
@@ -339,7 +374,13 @@ export async function buildRecordsBackupSection(
|
||||
deletedAt: r.deletedAt?.toISOString() ?? null,
|
||||
note: null,
|
||||
}
|
||||
: { note: decryptNoteSoft(r.noteEncrypted) }),
|
||||
: {
|
||||
note: readForExport(
|
||||
r.noteEncrypted,
|
||||
"lab note",
|
||||
decryptNoteFromBytes,
|
||||
),
|
||||
}),
|
||||
panel: r.panel,
|
||||
analyte: r.analyte,
|
||||
value: r.value,
|
||||
@@ -369,7 +410,11 @@ export async function buildRecordsBackupSection(
|
||||
upperBound: b.upperBound,
|
||||
panel: b.panel,
|
||||
hidden: b.hidden,
|
||||
context: decryptContextSoft(b.contextEncrypted),
|
||||
context: readForExport(
|
||||
b.contextEncrypted,
|
||||
"biomarker context",
|
||||
decryptContextFromBytes,
|
||||
),
|
||||
}));
|
||||
|
||||
const illnessEpisodes: CanonicalIllnessEpisode[] = disasterRecovery
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* What an export writes where a row could not be decrypted.
|
||||
*
|
||||
* A portable export carries plaintext, so an encrypted column that this
|
||||
* instance can no longer read has to become something. The fail-soft decrypt
|
||||
* helpers answer `null`, which in an export file is indistinguishable from a
|
||||
* column that was never written — and a restore then writes nothing, so the
|
||||
* loss is complete and silent. It is not: the ciphertext was there, and the
|
||||
* person is entitled to know a key gap ate it rather than to find a note they
|
||||
* remember writing simply gone.
|
||||
*
|
||||
* The marker is a sentence, not an empty string, for the same reason the Coach
|
||||
* transcript's placeholder is: an empty string reads as "there was nothing
|
||||
* here", and that is not what happened. It survives a round trip through the
|
||||
* restore importer like any other text, so the gap stays visible in the
|
||||
* restored account instead of closing over.
|
||||
*/
|
||||
export const UNREADABLE_EXPORT_MARKER =
|
||||
"[unreadable: encrypted with a key this instance no longer holds]";
|
||||
@@ -177,8 +177,13 @@ function adherenceObservations(
|
||||
for (const [name, comp] of Object.entries(data.compliance)) {
|
||||
if (comp.total <= 0) continue;
|
||||
// Integer percent — the one canonical rounding the app card + PDF use, so
|
||||
// a clinician sees the same adherence figure on every surface.
|
||||
const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0;
|
||||
// a clinician sees the same adherence figure on every surface. The helper
|
||||
// returns null exactly when nothing was expected, and a `?? 0` here would
|
||||
// put a clinical claim of 0 % adherence into an exported bundle in that
|
||||
// case. The `total <= 0` guard above already means it cannot happen; skip
|
||||
// rather than coalesce so it stays impossible if that guard ever moves.
|
||||
const rate = adherenceRatePercent(comp.taken, comp.total);
|
||||
if (rate === null) continue;
|
||||
observations.push({
|
||||
resourceType: "Observation",
|
||||
id: nextId(),
|
||||
|
||||
@@ -260,3 +260,76 @@ describe("generateBloodPressureStatusForUser — token-leak hardening (v1.4.27 F
|
||||
expect(parsed.text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
// The BP-status gate reads its medications' adherence to decide what to say
|
||||
// about a reading. `calculateCompliance` answers `rate: 100, totalExpected: 0`
|
||||
// for a medication with no schedule, so a blood-pressure medication saved
|
||||
// without one told the model the person was perfectly adherent — the exact
|
||||
// fabrication the doctor report has excluded since it was written.
|
||||
describe("generateBloodPressureStatusForUser — no rate without an expectation", () => {
|
||||
const SCHEDULE = {
|
||||
id: "sch-1",
|
||||
windowStart: "07:00",
|
||||
windowEnd: "08:00",
|
||||
timesOfDay: ["07:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
doseWindows: [],
|
||||
};
|
||||
|
||||
function bpMed(id: string, name: string, schedules: unknown[]) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date(Date.now() - 60 * dayMs),
|
||||
schedules,
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("omits a blood-pressure medication that expects no dose", async () => {
|
||||
const now = new Date();
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "BLOOD_PRESSURE_SYS", value: 132, measuredAt: now },
|
||||
{ type: "BLOOD_PRESSURE_DIA", value: 84, measuredAt: now },
|
||||
] as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
bpMed("med-empty", "Unscheduled", []),
|
||||
bpMed("med-real", "Amlodipine", [SCHEDULE]),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
vi.mocked(getMedicationCategories).mockResolvedValue({
|
||||
"med-empty": "BLOOD_PRESSURE",
|
||||
"med-real": "BLOOD_PRESSURE",
|
||||
});
|
||||
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK"}', captured);
|
||||
|
||||
await generateBloodPressureStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
const names = snapshot.bpMedications.map((m: { name: string }) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
expect(names).toContain("Amlodipine");
|
||||
});
|
||||
});
|
||||
@@ -360,3 +360,85 @@ describe("generateMedicationComplianceStatusForUser — token-leak hardening (v1
|
||||
expect(parsed.medications[0].text).not.toContain("metric:");
|
||||
});
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose. `calculateCompliance` answers
|
||||
// `rate: 100, totalExpected: 0` for that case, and this surface averaged that
|
||||
// 100 into `overall.averageCompliance30` and handed it to the model as the
|
||||
// person's adherence. The doctor report has excluded the empty schedule
|
||||
// alongside PRN since it was written — "so the report never prints a
|
||||
// fabricated 100 %" — and the same reason applies wherever a rate is shown.
|
||||
describe("generateMedicationComplianceStatusForUser — no rate without an expectation", () => {
|
||||
function scheduleless(now: Date) {
|
||||
return {
|
||||
id: "med-empty",
|
||||
name: "Unscheduled",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
asNeeded: false,
|
||||
createdAt: new Date(now.getTime() - 60 * dayMs),
|
||||
schedules: [],
|
||||
scheduleRevisions: [],
|
||||
pauseEras: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps a schedule-less medication out of the snapshot and its average", async () => {
|
||||
const now = new Date();
|
||||
const scheduled = { ...medFixture(now), asNeeded: false };
|
||||
const events = Array.from({ length: 30 }, (_, day) => {
|
||||
const scheduledFor = new Date(now.getTime() - day * dayMs);
|
||||
// Half the doses taken, so the real medication sits well below 100 %.
|
||||
return {
|
||||
medicationId: "med-1",
|
||||
scheduledFor,
|
||||
takenAt: day % 2 === 0 ? scheduledFor : null,
|
||||
skipped: false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
scheduled,
|
||||
scheduleless(now),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
events as never,
|
||||
);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const captured: { userPrompt: string | null } = { userPrompt: null };
|
||||
stubCompletion('{"summary":"OK","medications":[]}', captured);
|
||||
|
||||
await generateMedicationComplianceStatusForUser("user-1", { locale: "en" });
|
||||
|
||||
const snapshot = JSON.parse(captured.userPrompt!.match(/\{[\s\S]*\}/)![0]);
|
||||
const names = snapshot.medications.map((m: { name: string }) => m.name);
|
||||
expect(names).not.toContain("Unscheduled");
|
||||
expect(names).toContain("Ramipril");
|
||||
expect(snapshot.overall.medicationCount).toBe(1);
|
||||
// The fabricated 100 is no longer half of the average handed to the model.
|
||||
expect(snapshot.overall.averageCompliance30).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("reports no active medications when the only one has no schedule", async () => {
|
||||
const now = new Date();
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
scheduleless(now),
|
||||
] as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
const outcome = (await generateMedicationComplianceStatusForUser("user-1", {
|
||||
locale: "en",
|
||||
})) as { summary: string | null; medications: unknown[] };
|
||||
|
||||
expect(outcome.medications).toEqual([]);
|
||||
expect(outcome.summary).toContain("no active medications");
|
||||
// Nothing to assess, so no model call and no cache row either.
|
||||
expect(runStatusCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -292,25 +292,43 @@ describe("computeHeatmapCells", () => {
|
||||
|
||||
describe("computeMoodMetricCorrelation", () => {
|
||||
it("pairs on dayOffset and returns scatter points + a coefficient", () => {
|
||||
const moodDaily = [
|
||||
{ dayOffset: 0, value: 5 },
|
||||
{ dayOffset: 1, value: 4 },
|
||||
{ dayOffset: 2, value: 3 },
|
||||
{ dayOffset: 3, value: 2 },
|
||||
{ dayOffset: 4, value: 1 },
|
||||
];
|
||||
// Twenty days, not five. This test used to run on five and assert a
|
||||
// coefficient came back, which pinned the very thing the significance
|
||||
// gate exists to refuse: five points that happen to line up are not a
|
||||
// finding. The pairing and the scatter projection are what it is asking
|
||||
// about, and they are unchanged — it just has to ask above the bar.
|
||||
const moodDaily = Array.from({ length: 20 }, (_, i) => ({
|
||||
dayOffset: i,
|
||||
value: 5 - (i % 5),
|
||||
}));
|
||||
// Perfectly positively correlated metric.
|
||||
const metricDaily = moodDaily.map((b) => ({
|
||||
dayOffset: b.dayOffset,
|
||||
value: b.value * 10,
|
||||
}));
|
||||
const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW);
|
||||
expect(corr.n).toBe(5);
|
||||
expect(corr.points).toHaveLength(5);
|
||||
expect(corr.n).toBe(20);
|
||||
expect(corr.points).toHaveLength(20);
|
||||
expect(corr.result?.r).toBe(1);
|
||||
expect(corr.suppressed).toBeUndefined();
|
||||
expect(corr.points[0]).toEqual({ x: 5, y: 50 });
|
||||
});
|
||||
|
||||
it("refuses the same shape at five days", () => {
|
||||
const moodDaily = Array.from({ length: 5 }, (_, i) => ({
|
||||
dayOffset: i,
|
||||
value: 5 - i,
|
||||
}));
|
||||
const metricDaily = moodDaily.map((b) => ({
|
||||
dayOffset: b.dayOffset,
|
||||
value: b.value * 10,
|
||||
}));
|
||||
const corr = computeMoodMetricCorrelation(moodDaily, metricDaily, NOW);
|
||||
expect(corr.n).toBe(5);
|
||||
expect(corr.result).toBeNull();
|
||||
expect(corr.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
|
||||
it("returns a null coefficient below the minimum pair count", () => {
|
||||
const moodDaily = [
|
||||
{ dayOffset: 0, value: 5 },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The mood page's cross-metric correlations clear the same bar as every
|
||||
* other user-facing correlation surface.
|
||||
*
|
||||
* `significantPearsonCorrelation` was written for exactly this failure — its
|
||||
* own comment names a five-point r ≈ 0.7 fluke rendering as "stark" — and the
|
||||
* weight, blood-pressure and mood-status surfaces were moved onto it. The
|
||||
* mood page stayed on the plain `pearsonCorrelation` with its floor of five
|
||||
* pairs and no significance test, so the same five points that the other
|
||||
* surfaces refuse still painted a "strong" badge here.
|
||||
*
|
||||
* These assertions are about what the reader is told, not about which helper
|
||||
* is called: below the bar there is no coefficient to show, and the card is
|
||||
* handed a reason it can name instead of a blank.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeMoodMetricCorrelation,
|
||||
type DailyPoint,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
|
||||
const NOW = new Date("2026-06-01T12:00:00.000Z");
|
||||
|
||||
/** One point per day-offset, values taken from the list in order. */
|
||||
function series(values: number[]): DailyPoint[] {
|
||||
return values.map((value, i) => ({ dayOffset: i, value }));
|
||||
}
|
||||
|
||||
describe("computeMoodMetricCorrelation — significance gate", () => {
|
||||
it("refuses a five-day strong-looking slope and says the pairs are too few", () => {
|
||||
const mood = series([1, 2, 3, 4, 5]);
|
||||
const metric = series([1, 2, 2, 4, 3]);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.n).toBe(5);
|
||||
// No coefficient reaches the card, so no strength band can be painted.
|
||||
expect(out.result).toBeNull();
|
||||
// ... and the card is told WHY, so it can say so rather than showing an
|
||||
// unexplained blank.
|
||||
expect(out.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
|
||||
it("refuses twenty noisy days as not significant rather than as too few", () => {
|
||||
const ys = [5, 2, 7, 1, 6, 3, 8, 2, 5, 4, 6, 1, 7, 3, 5, 2, 6, 4, 5, 3];
|
||||
const mood = series(ys.map((_, i) => i));
|
||||
const metric = series(ys);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.n).toBe(20);
|
||||
expect(out.result).toBeNull();
|
||||
expect(out.suppressed).toBe("notSignificant");
|
||||
});
|
||||
|
||||
it("surfaces twenty days of a real relationship", () => {
|
||||
const mood = series(Array.from({ length: 20 }, (_, i) => i));
|
||||
const metric = series(
|
||||
Array.from({ length: 20 }, (_, i) => i * 2 + (i % 2 === 0 ? 0.3 : -0.3)),
|
||||
);
|
||||
|
||||
const out = computeMoodMetricCorrelation(mood, metric, NOW);
|
||||
|
||||
expect(out.suppressed).toBeUndefined();
|
||||
expect(out.result).not.toBeNull();
|
||||
expect(out.result!.n).toBe(20);
|
||||
expect(out.result!.strength).toBe("stark");
|
||||
});
|
||||
|
||||
it("reports no overlap as too few pairs, not as an insignificant finding", () => {
|
||||
const out = computeMoodMetricCorrelation(series([3, 4]), [], NOW);
|
||||
|
||||
expect(out.n).toBe(0);
|
||||
expect(out.result).toBeNull();
|
||||
expect(out.suppressed).toBe("insufficientPairs");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -324,7 +325,7 @@ export async function prepareBloodPressureStatusForUser(
|
||||
weightVsSystolicPairs,
|
||||
);
|
||||
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
const activeMedicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never feed the BP-status
|
||||
// compliance gate (no expected doses, no rate).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -339,6 +340,12 @@ export async function prepareBloodPressureStatusForUser(
|
||||
},
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const activeMedications = activeMedicationRows.filter(expectsDoses);
|
||||
|
||||
const categoryMap = await getMedicationCategories(
|
||||
activeMedications.map((medication) => medication.id),
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildComplianceMedicationContext,
|
||||
buildMedicationComplianceBundle,
|
||||
dailyComplianceRatesFromLedger,
|
||||
expectsDoses,
|
||||
lastNonSkippedTakenAt,
|
||||
SCHEDULE_COMPLIANCE_SELECT,
|
||||
} from "@/lib/analytics/compliance";
|
||||
@@ -208,7 +209,7 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
};
|
||||
}
|
||||
|
||||
const medications = await prisma.medication.findMany({
|
||||
const medicationRows = await prisma.medication.findMany({
|
||||
// v1.16.11 — as-needed (PRN) medications never surface a compliance
|
||||
// rate (no expected doses).
|
||||
where: { userId, active: true, asNeeded: false },
|
||||
@@ -224,6 +225,12 @@ export async function prepareMedicationComplianceStatusForUser(
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
// A medication with no schedule expects no dose, and `calculateCompliance`
|
||||
// answers 100 % for that case. The `asNeeded: false` filter above catches
|
||||
// only the PRN arm; the empty schedule is reachable on a scheduled
|
||||
// medication and would otherwise report perfect adherence.
|
||||
const medications = medicationRows.filter(expectsDoses);
|
||||
|
||||
if (medications.length === 0) {
|
||||
return {
|
||||
phase: "served",
|
||||
|
||||
@@ -20,10 +20,11 @@
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
pearsonCorrelation,
|
||||
significantPearsonCorrelation,
|
||||
type CorrelationResult,
|
||||
type PairedPoint,
|
||||
} from "@/lib/analytics/correlations";
|
||||
import { MIN_PAIRED_N } from "@/lib/insights/correlations";
|
||||
import {
|
||||
applyPayloadBudget,
|
||||
dayOffsetToBerlinDayKey,
|
||||
@@ -384,11 +385,20 @@ export interface CorrelationScatterPoint {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
/** Why a pairing produced no coefficient. */
|
||||
export type MoodCorrelationSuppression = "insufficientPairs" | "notSignificant";
|
||||
|
||||
export interface MoodMetricCorrelation {
|
||||
result: CorrelationResult | null;
|
||||
/** Mood (x) vs metric (y) scatter points, paired by day. */
|
||||
points: CorrelationScatterPoint[];
|
||||
n: number;
|
||||
/**
|
||||
* Which bar the pairing fell short of, so the card can name it rather
|
||||
* than showing a blank that reads the same whether nothing was logged or
|
||||
* a full year of days simply did not line up. Absent when `result` is set.
|
||||
*/
|
||||
suppressed?: MoodCorrelationSuppression;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,6 +406,14 @@ export interface MoodMetricCorrelation {
|
||||
* series, paired on `dayOffset`. Returns the scatter points so the
|
||||
* caller can paint a `<ScatterCorrelationChart>` from the same pairing
|
||||
* the coefficient was computed over.
|
||||
*
|
||||
* This routes through `significantPearsonCorrelation` — n >= 20 AND
|
||||
* p < 0.05 — for the same reason the weight, blood-pressure and
|
||||
* mood-status surfaces do: the plain `pearsonCorrelation` bands from |r|
|
||||
* alone above a floor of five pairs, so five days of noise sloping the
|
||||
* right way arrive at the reader labelled "strong". A mood page is where
|
||||
* that costs the most, because the reader is looking for something to
|
||||
* change about their week.
|
||||
*/
|
||||
export function computeMoodMetricCorrelation(
|
||||
moodDaily: DailyPoint[],
|
||||
@@ -404,9 +422,16 @@ export function computeMoodMetricCorrelation(
|
||||
tz: string = DEFAULT_TIMEZONE,
|
||||
): MoodMetricCorrelation {
|
||||
const pairs = pairDailyBuckets(moodDaily, metricDaily, now, tz);
|
||||
const result = pearsonCorrelation(pairs);
|
||||
const points = pairs.map((p) => ({ x: p.a, y: p.b }));
|
||||
return { result, points, n: pairs.length };
|
||||
const result = significantPearsonCorrelation(pairs);
|
||||
if (result) return { result, points, n: pairs.length };
|
||||
return {
|
||||
result: null,
|
||||
points,
|
||||
n: pairs.length,
|
||||
suppressed:
|
||||
pairs.length < MIN_PAIRED_N ? "insufficientPairs" : "notSignificant",
|
||||
};
|
||||
}
|
||||
|
||||
// --- Orchestrated aggregate shape ---
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -33,10 +33,12 @@ export function decryptContextFromBytes(buf: Uint8Array): string {
|
||||
|
||||
/** Decrypt a stored `Bytes` context note, fail-soft to `null` on any error.
|
||||
*
|
||||
* Mirrors `decryptNoteSoft` in the illness DTO layer: a single bad-key /
|
||||
* malformed row must not 500 the whole catalog list. The single-resource GET
|
||||
* path uses the throwing `decryptContextFromBytes` so a genuine decrypt
|
||||
* failure there surfaces instead of silently masking a key-rotation gap. */
|
||||
* A single bad-key / malformed row must not 500 the whole catalog list, and
|
||||
* the list surface renders a missing context as nothing either way. The
|
||||
* single-resource GET path uses the throwing `decryptContextFromBytes` so a
|
||||
* genuine decrypt failure there surfaces instead of silently masking a
|
||||
* key-rotation gap, and the backup export reads through its own helper that
|
||||
* marks the row rather than blanking it. */
|
||||
export function decryptContextSoft(buf: Uint8Array | null): string | null {
|
||||
if (!buf) return null;
|
||||
try {
|
||||
|
||||
+5
-24
@@ -5,11 +5,15 @@
|
||||
* free-text note is the only sensitive column on the model; it shares the
|
||||
* `encrypt()` string format (`"<keyId>.<base64>"`) every other `*Encrypted`
|
||||
* column uses, encoded as UTF-8 bytes.
|
||||
*
|
||||
* There is one decrypt here and it throws. A fail-soft twin used to sit
|
||||
* beside it for bulk reads; the list DTO never carries the note (it reports
|
||||
* `hasNote` only) and the export now writes a visible marker instead of a
|
||||
* null, so nothing was left that wanted a silent one.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
/** Encrypt a UTF-8 note into the `Bytes` payload the schema stores. */
|
||||
export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
|
||||
@@ -26,26 +30,3 @@ export function encryptNoteToBytes(plaintext: string): Uint8Array<ArrayBuffer> {
|
||||
export function decryptNoteFromBytes(buf: Uint8Array): string {
|
||||
return decrypt(Buffer.from(buf).toString("utf8"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored `Bytes` note, fail-soft to `null` on any error.
|
||||
*
|
||||
* Mirrors `decryptContextSoft` in the biomarker store: a single bad-key /
|
||||
* malformed row must not fail a bulk read (list view, backup export) that
|
||||
* spans many rows. The single-resource GET path uses the throwing
|
||||
* `decryptNoteFromBytes` so a genuine decrypt failure there surfaces instead
|
||||
* of silently masking a key-rotation gap.
|
||||
*/
|
||||
export function decryptNoteSoft(buf: Uint8Array | null): string | null {
|
||||
if (!buf || buf.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptNoteFromBytes(buf);
|
||||
} catch (err) {
|
||||
getEvent()?.addWarning(
|
||||
`lab note decrypt failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ import {
|
||||
encryptWaveformToBytes,
|
||||
} from "@/lib/withings/ecg-waveform-codec";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { decryptNoteFromBytes } from "@/lib/labs/store";
|
||||
import { decryptContextFromBytes } from "@/lib/labs/biomarker-store";
|
||||
import { TWO_ENDED_MODELS, type TwoEndedModel } from "@/lib/export/backup-plan";
|
||||
import { POST } from "@/app/api/admin/backups/[id]/restore/route";
|
||||
|
||||
@@ -2619,4 +2622,103 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A note this instance can no longer decrypt leaves the export as a marker,
|
||||
* not as nothing.
|
||||
*
|
||||
* A fail-soft decrypt answers `null` on a wrong or dropped key, and in a
|
||||
* portable file `null` is byte-for-byte what a note that was never written
|
||||
* looks like. The restore importer then writes no note, so a note the person
|
||||
* did write is gone and no one is told — the whole loss fits inside a
|
||||
* fail-soft `catch` written for a list view, where returning null is right.
|
||||
*
|
||||
* The Coach transcript and the doctor report both refuse that trade, one
|
||||
* with a placeholder sentence and one with a rendered flag. This asserts the
|
||||
* lab note now does the same, and that the marker is still there after the
|
||||
* file has been through a real restore: the ciphertext is unreadable either
|
||||
* way, but a person opening the restored account can see that something was
|
||||
* there rather than believing they never wrote it.
|
||||
*/
|
||||
it("carries a lab note it cannot decrypt through the round trip as a visible marker", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await createOwner(prisma);
|
||||
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
|
||||
});
|
||||
// Ciphertext under a key id this instance does not hold — the shape a row
|
||||
// written before a key rotation that dropped its key has. `decrypt` is
|
||||
// fail-closed on an unknown key id, so this throws rather than returning
|
||||
// garbage, which is the case the soft helper swallowed.
|
||||
const unreadable = new Uint8Array(
|
||||
Buffer.from("v9.AAAAAAAAAAAAAAAAAAAAAAAA", "utf8"),
|
||||
);
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
biomarkerId: biomarker.id,
|
||||
analyte: "Ferritin",
|
||||
value: 91,
|
||||
unit: "ng/mL",
|
||||
takenAt: AT("2026-06-30T09:00:00.000Z"),
|
||||
noteEncrypted: unreadable,
|
||||
},
|
||||
});
|
||||
await prisma.biomarker.update({
|
||||
where: { id: biomarker.id },
|
||||
data: { contextEncrypted: unreadable },
|
||||
});
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
||||
purpose: "portable-export",
|
||||
});
|
||||
|
||||
const exported = payload.labResults as Array<{ note: string | null }>;
|
||||
expect(exported).toHaveLength(1);
|
||||
expect(exported[0].note).toContain("unreadable");
|
||||
const exportedBiomarkers = payload.biomarkers as Array<{
|
||||
context: string | null;
|
||||
}>;
|
||||
expect(exportedBiomarkers[0].context).toContain("unreadable");
|
||||
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "TWO_ENDED_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
|
||||
const restoredLab = await prisma.labResult.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
// Back in the column it came from, readable again because the restore
|
||||
// re-encrypted it under the CURRENT key — and saying what happened.
|
||||
expect(restoredLab.noteEncrypted).not.toBeNull();
|
||||
expect(decryptNoteFromBytes(restoredLab.noteEncrypted!)).toBe(
|
||||
UNREADABLE_EXPORT_MARKER,
|
||||
);
|
||||
|
||||
const restoredBiomarker = await prisma.biomarker.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect(decryptContextFromBytes(restoredBiomarker.contextEncrypted!)).toBe(
|
||||
UNREADABLE_EXPORT_MARKER,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user