feat(doctor-report): server filter respects per-section toggles + PDF skips disabled sections

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:29:53 +02:00
1 parent 330be965ac
commit 759fdaefcd
4 files changed
+281 -2

No files matched your search

+24 -1
View File
@@ -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;
}
/**
+16 -1
View File
@@ -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);
});
});