From 7c06b897177dbec8a9db4147a0fee89e398d28ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 13:17:48 +0200 Subject: [PATCH] feat(schema): User.doctorReportPrefsJson + migration 0045 --- .../0045_doctor_report_prefs/migration.sql | 24 +++++ prisma/schema.prisma | 14 +++ .../__tests__/doctor-report-prefs.test.ts | 97 ++++++++++++++++++ src/lib/validations/doctor-report-prefs.ts | 99 +++++++++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 prisma/migrations/0045_doctor_report_prefs/migration.sql create mode 100644 src/lib/validations/__tests__/doctor-report-prefs.test.ts create mode 100644 src/lib/validations/doctor-report-prefs.ts diff --git a/prisma/migrations/0045_doctor_report_prefs/migration.sql b/prisma/migrations/0045_doctor_report_prefs/migration.sql new file mode 100644 index 000000000..621d3f8b4 --- /dev/null +++ b/prisma/migrations/0045_doctor_report_prefs/migration.sql @@ -0,0 +1,24 @@ +-- v1.4.25 W6c — per-user Doctor-Report section toggles. +-- +-- The doctor-report dialog used to print every available data type +-- whether the user wanted it or not. Marc's directive 2026-05-14: users +-- decide which sections appear in their PDF, and the choice persists so +-- a follow-up export starts from the same picks. +-- +-- Shape: +-- { "bp": boolean, +-- "weight": boolean, +-- "pulse": boolean, +-- "bmi": boolean, +-- "mood": boolean, -- privacy-sensitive; default OFF +-- "compliance": boolean, +-- "sleep": boolean } +-- +-- Null = "use defaults": every section ON except mood. The privacy +-- default for mood is intentional — mental-health data should be opt-in, +-- not opt-out, even within a single user's own surface. +-- +-- Forward-only, additive, idempotent. No backfill: a NULL row maps to +-- the documented defaults at read time. +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "doctor_report_prefs_json" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9ca1637fa..8724dfd90 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -127,6 +127,20 @@ model User { // model would still see the data the user excluded. coachPrefsJson Json? @map("coach_prefs_json") + // v1.4.25 W6c — Doctor-report section toggles. + // Shape: + // { "bp": boolean, + // "weight": boolean, + // "pulse": boolean, + // "bmi": boolean, + // "mood": boolean, // privacy-sensitive; default OFF + // "compliance": boolean, + // "sleep": boolean } + // Null = documented defaults (every section ON except mood). Mood is + // opt-in by default — the API layer drops mood entirely from the + // payload when the toggle is off so the data never leaves the DB row. + doctorReportPrefsJson Json? @map("doctor_report_prefs_json") + // Relations passkeys Passkey[] sessions Session[] diff --git a/src/lib/validations/__tests__/doctor-report-prefs.test.ts b/src/lib/validations/__tests__/doctor-report-prefs.test.ts new file mode 100644 index 000000000..52137b5d9 --- /dev/null +++ b/src/lib/validations/__tests__/doctor-report-prefs.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_DOCTOR_REPORT_PREFS, + doctorReportPrefsSchema, + parseDoctorReportPrefs, + resolveDoctorReportPrefs, +} from "../doctor-report-prefs"; + +/** + * v1.4.25 W6c — per-user Doctor-Report section toggles. + */ +describe("doctorReportPrefsSchema", () => { + it("accepts an empty object (every key is optional)", () => { + const out = doctorReportPrefsSchema.parse({}); + expect(out).toEqual({}); + }); + + it("accepts the full shape and round-trips it", () => { + const input = { + bp: true, + weight: false, + pulse: true, + bmi: true, + mood: false, + compliance: true, + sleep: false, + }; + const out = doctorReportPrefsSchema.parse(input); + expect(out).toEqual(input); + }); + + it("accepts a partial update (only the toggled keys)", () => { + const out = doctorReportPrefsSchema.parse({ mood: true }); + expect(out).toEqual({ mood: true }); + }); + + it("rejects a non-boolean value", () => { + const result = doctorReportPrefsSchema.safeParse({ bp: "yes" }); + expect(result.success).toBe(false); + }); +}); + +describe("parseDoctorReportPrefs", () => { + it("returns defaults for null input", () => { + expect(parseDoctorReportPrefs(null)).toEqual(DEFAULT_DOCTOR_REPORT_PREFS); + }); + + it("returns defaults for undefined input", () => { + expect(parseDoctorReportPrefs(undefined)).toEqual( + DEFAULT_DOCTOR_REPORT_PREFS, + ); + }); + + it("defaults mood to OFF (privacy directive per Marc)", () => { + const out = parseDoctorReportPrefs(null); + expect(out.mood).toBe(false); + }); + + it("fills missing keys from defaults", () => { + const out = parseDoctorReportPrefs({ bp: false }); + expect(out).toEqual({ + ...DEFAULT_DOCTOR_REPORT_PREFS, + bp: false, + }); + }); + + it("returns defaults for malformed input (forward-compat fallback)", () => { + expect(parseDoctorReportPrefs({ bp: "nope" })).toEqual( + DEFAULT_DOCTOR_REPORT_PREFS, + ); + expect(parseDoctorReportPrefs("not an object")).toEqual( + DEFAULT_DOCTOR_REPORT_PREFS, + ); + }); +}); + +describe("resolveDoctorReportPrefs", () => { + it("layers a partial update over current persisted values", () => { + const current = { + bp: false, + weight: false, + pulse: false, + bmi: false, + mood: true, + compliance: false, + sleep: false, + }; + const out = resolveDoctorReportPrefs(current, { mood: false }); + expect(out).toEqual({ ...current, mood: false }); + }); + + it("layers a partial update over defaults when current is null", () => { + const out = resolveDoctorReportPrefs(null, { mood: true }); + expect(out).toEqual({ ...DEFAULT_DOCTOR_REPORT_PREFS, mood: true }); + }); +}); diff --git a/src/lib/validations/doctor-report-prefs.ts b/src/lib/validations/doctor-report-prefs.ts new file mode 100644 index 000000000..4ddd9503e --- /dev/null +++ b/src/lib/validations/doctor-report-prefs.ts @@ -0,0 +1,99 @@ +/** + * v1.4.25 W6c — per-user Doctor-Report section toggles. + * + * Persisted as a Json blob on `User.doctorReportPrefsJson`. Null = the + * documented defaults (every section ON except mood). The dialog reads + * the row, lets the user flip checkboxes, and writes the chosen shape + * back. The PDF generator + the report aggregator both consult the + * shape: aggregator drops mood data entirely when `mood = false` so the + * data never leaves the DB row (privacy-by-default), and the generator + * skips each section whose flag is false at render time. + * + * The shape is intentionally additive — every new data type added to the + * report grows the schema with a new optional flag and a new entry in + * `DEFAULT_DOCTOR_REPORT_PREFS`. Forward-compat: an unknown / drifted + * shape falls back to defaults rather than throwing. + */ +import { z } from "zod/v4"; + +/** + * Section toggle schema. Every key is optional so a partial update from + * the dialog (e.g., "the user only flipped mood") doesn't have to + * re-state every other flag. The route layer fills missing keys from the + * defaults before persisting so the column shape stays stable. + */ +export const doctorReportPrefsSchema = z + .object({ + bp: z.boolean(), + weight: z.boolean(), + pulse: z.boolean(), + bmi: z.boolean(), + mood: z.boolean(), + compliance: z.boolean(), + sleep: z.boolean(), + }) + .partial(); + +export type DoctorReportPrefsInput = z.infer; + +/** + * Fully-resolved section toggles. Every key required so the consumers + * (PDF renderer + aggregator) don't have to thread an "is this key + * present?" check through their render paths. + */ +export interface DoctorReportPrefs { + bp: boolean; + weight: boolean; + pulse: boolean; + bmi: boolean; + mood: boolean; + compliance: boolean; + sleep: boolean; +} + +/** + * Defaults applied when the user has never opened the dialog. Every + * section is ON by default EXCEPT mood, which is opt-in per Marc's + * privacy directive (2026-05-14): mental-health data should never appear + * in a clinical PDF the user didn't explicitly check. + */ +export const DEFAULT_DOCTOR_REPORT_PREFS: DoctorReportPrefs = { + bp: true, + weight: true, + pulse: true, + bmi: true, + mood: false, // privacy default per Marc + compliance: true, + sleep: true, +}; + +/** + * Parse a row's `doctorReportPrefsJson` Json blob into a typed + * `DoctorReportPrefs`, falling back to the documented defaults when the + * row is null OR the persisted shape has drifted (a forward-compat field + * rename, an admin-side hand-edit, etc.). Missing keys are filled from + * the defaults so callers always get a fully-resolved object. + */ +export function parseDoctorReportPrefs(raw: unknown): DoctorReportPrefs { + if (raw == null) return { ...DEFAULT_DOCTOR_REPORT_PREFS }; + const parsed = doctorReportPrefsSchema.safeParse(raw); + if (!parsed.success) return { ...DEFAULT_DOCTOR_REPORT_PREFS }; + return { + ...DEFAULT_DOCTOR_REPORT_PREFS, + ...parsed.data, + }; +} + +/** + * Resolve a partial input (typically from a PUT body) into the full + * canonical shape, layering the supplied keys over the current + * persisted row (or defaults when null). Keeps the route layer free of + * merge plumbing. + */ +export function resolveDoctorReportPrefs( + current: unknown, + incoming: DoctorReportPrefsInput, +): DoctorReportPrefs { + const base = parseDoctorReportPrefs(current); + return { ...base, ...incoming }; +}