mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(doctor-report): server filter respects per-section toggles + PDF skips disabled sections
This commit is contained in:
1 parent
330be965ac
commit
759fdaefcd
4 files changed
+281
-2
No files matched your search
@@ -11,6 +11,11 @@ import {
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import {
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
doctorReportPrefsSchema,
|
||||
type DoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
@@ -48,6 +53,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rawPracticeName = body?.practiceName;
|
||||
const practiceName = sanitisePracticeName(rawPracticeName);
|
||||
|
||||
// v1.4.25 W6c — section toggles. Malformed shapes silently fall back
|
||||
// to documented defaults so a forward-compat client never lands a
|
||||
// 422 on a benign drift.
|
||||
const sectionsParsed = doctorReportPrefsSchema.safeParse(
|
||||
body?.sections ?? {},
|
||||
);
|
||||
const sectionsInput = sectionsParsed.success ? sectionsParsed.data : {};
|
||||
const sections: DoctorReportPrefs = {
|
||||
...DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
...sectionsInput,
|
||||
};
|
||||
|
||||
if (typeof rawPracticeName === "string" && practiceName !== null) {
|
||||
try {
|
||||
await prisma.user.update({
|
||||
@@ -60,7 +77,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const [data, userTz] = await Promise.all([
|
||||
collectDoctorReportData(user.id, range, { practiceName }),
|
||||
collectDoctorReportData(user.id, range, { practiceName, sections }),
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
|
||||
@@ -76,6 +93,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
endDate: range.end.toISOString(),
|
||||
locale,
|
||||
practiceNameProvided: practiceName !== null,
|
||||
// v1.4.25 W6c — record exactly which sections this report
|
||||
// rendered, so a future privacy review can confirm mood was
|
||||
// never rendered when the user opted out.
|
||||
sections,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,6 +132,8 @@ interface PdfRequestBody {
|
||||
endDate?: unknown;
|
||||
locale?: unknown;
|
||||
practiceName?: unknown;
|
||||
/** v1.4.25 W6c — per-section visibility toggles. */
|
||||
sections?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
sanitisePracticeName,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { doctorReportPrefsSchema } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
/**
|
||||
* Collect data for doctor report PDF generation (client-side).
|
||||
@@ -51,6 +52,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
?.practiceName;
|
||||
const practiceName = sanitisePracticeName(rawPracticeName);
|
||||
|
||||
// v1.4.25 W6c — section toggles. Malformed shapes silently fall
|
||||
// through to the documented defaults so a forward-compat client
|
||||
// never lands a 422 on a benign drift.
|
||||
const rawSections = (body as Record<string, unknown> | null)?.sections;
|
||||
const sectionsParsed = doctorReportPrefsSchema.safeParse(rawSections ?? {});
|
||||
const sections = sectionsParsed.success ? sectionsParsed.data : {};
|
||||
|
||||
// Persist the most-recent practice name as a user preference so the
|
||||
// dialog can pre-fill it next time. We only write when the caller
|
||||
// actually supplied a non-empty string — passing `null`/empty does NOT
|
||||
@@ -67,7 +75,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const data = await collectDoctorReportData(user.id, range, { practiceName });
|
||||
const data = await collectDoctorReportData(user.id, range, {
|
||||
practiceName,
|
||||
sections,
|
||||
});
|
||||
|
||||
await auditLog("doctor-report.generate", {
|
||||
userId: user.id,
|
||||
@@ -77,6 +88,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
startDate: range.start.toISOString(),
|
||||
endDate: range.end.toISOString(),
|
||||
practiceNameProvided: practiceName !== null,
|
||||
// v1.4.25 W6c — record which sections were included. The audit
|
||||
// trail proves mood data was never aggregated when the toggle
|
||||
// was off (privacy contract).
|
||||
sections,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* v1.4.25 W6c — per-section toggle integration tests.
|
||||
*
|
||||
* Asserts the privacy-by-default contract for mood data: when the user
|
||||
* submits `sections.mood = false`, the generated PDF MUST NOT contain
|
||||
* any mood-related text, and the aggregator MUST NOT have fetched the
|
||||
* underlying `MoodEntry` rows in the first place.
|
||||
*
|
||||
* Also asserts the "skip section when toggle off" contract for the
|
||||
* non-privacy-sensitive sections (BP / weight / pulse / compliance) so
|
||||
* the dialog's toggles produce the expected printed artefact.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const RANGE = {
|
||||
start: new Date("2026-01-01T00:00:00.000Z"),
|
||||
end: new Date("2026-05-01T00:00:00.000Z"),
|
||||
days: 120,
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a user plus one row of every measurement type the toggles
|
||||
* gate. Returns the user id for the test to thread into the aggregator
|
||||
* and PDF renderer.
|
||||
*/
|
||||
async function seedUserWithEveryDataType(username: string): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
heightCm: 180,
|
||||
},
|
||||
});
|
||||
const measuredAt = new Date("2026-03-01T08:00:00.000Z");
|
||||
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 82.5,
|
||||
unit: "kg",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value: 128,
|
||||
unit: "mmHg",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "BLOOD_PRESSURE_DIA",
|
||||
value: 82,
|
||||
unit: "mmHg",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "PULSE",
|
||||
value: 65,
|
||||
unit: "bpm",
|
||||
measuredAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
score: 4,
|
||||
mood: "GUT",
|
||||
moodLoggedAt: measuredAt,
|
||||
date: "2026-03-01",
|
||||
},
|
||||
});
|
||||
|
||||
// Medication compliance — one taken intake event.
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: "Metformin",
|
||||
dose: "500 mg",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: med.id,
|
||||
scheduledFor: measuredAt,
|
||||
takenAt: measuredAt,
|
||||
},
|
||||
});
|
||||
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull plain UTF-8 text out of the PDF byte stream. jsPDF's text
|
||||
* operator stores the strings inline as `(...)` tokens, so a naive
|
||||
* regex scan over the buffer is enough to assert presence/absence of a
|
||||
* label — we don't need to actually decode the streams. False positives
|
||||
* are tolerable because the assertions are negative ("doesn't contain
|
||||
* `Stimmung`") and the strings are distinctive.
|
||||
*/
|
||||
function pdfContainsText(bytes: Uint8Array, needle: string): boolean {
|
||||
const haystack = Buffer.from(bytes).toString("latin1");
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
describe("doctor-report — per-section toggles", () => {
|
||||
it("drops mood data entirely when sections.mood = false", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-sections-mood-off");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
sections: {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: true,
|
||||
bmi: true,
|
||||
mood: false, // ← the privacy default
|
||||
compliance: true,
|
||||
sleep: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The aggregator must NOT return mood data when the toggle is off.
|
||||
// The contract: the data never leaves the DB row, so the JSON
|
||||
// payload (this `data` object) is the canonical proof.
|
||||
expect(data.mood).toBeNull();
|
||||
|
||||
// The PDF rendered from this data must not mention the German or
|
||||
// English mood section header — both locales render against the
|
||||
// same `data` payload so both must be clean.
|
||||
const { t: tDe } = getServerTranslator("de");
|
||||
const { t: tEn } = getServerTranslator("en");
|
||||
const pdfDe = renderDoctorReportPdfBytes(data, { t: tDe, locale: "de" });
|
||||
const pdfEn = renderDoctorReportPdfBytes(data, { t: tEn, locale: "en" });
|
||||
|
||||
expect(pdfContainsText(pdfDe, "Stimmung")).toBe(false);
|
||||
expect(pdfContainsText(pdfEn, "Mood")).toBe(false);
|
||||
});
|
||||
|
||||
it("includes mood data when sections.mood = true (opt-in)", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-sections-mood-on");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
sections: { mood: true },
|
||||
});
|
||||
|
||||
expect(data.mood).not.toBeNull();
|
||||
expect(data.mood?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("strips BP / weight / pulse / compliance when their toggles are off", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-sections-others-off");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE, {
|
||||
sections: {
|
||||
bp: false,
|
||||
weight: false,
|
||||
pulse: false,
|
||||
bmi: false,
|
||||
mood: false,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(data.stats.BLOOD_PRESSURE_SYS).toBeUndefined();
|
||||
expect(data.stats.BLOOD_PRESSURE_DIA).toBeUndefined();
|
||||
expect(data.stats.WEIGHT).toBeUndefined();
|
||||
expect(data.stats.PULSE).toBeUndefined();
|
||||
expect(data.compliance).toEqual({});
|
||||
expect(data.bmi).toBeNull();
|
||||
expect(data.mood).toBeNull();
|
||||
});
|
||||
|
||||
it("applies documented defaults when sections is omitted (mood OFF, others ON)", async () => {
|
||||
const userId = await seedUserWithEveryDataType("dr-sections-defaults");
|
||||
|
||||
const data = await collectDoctorReportData(userId, RANGE);
|
||||
|
||||
// Privacy default per Marc — mood is opt-in, not opt-out.
|
||||
expect(data.mood).toBeNull();
|
||||
// Every other section keeps its data.
|
||||
expect(data.stats.WEIGHT).toBeDefined();
|
||||
expect(data.stats.BLOOD_PRESSURE_SYS).toBeDefined();
|
||||
expect(data.stats.PULSE).toBeDefined();
|
||||
expect(data.bmi).not.toBeNull();
|
||||
expect(Object.keys(data.compliance).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user