feat(schema): User.doctorReportPrefsJson + migration 0045

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:19:17 +02:00
1 parent 58bbb4ec78
commit 7c06b89717
4 files changed
+234

No files matched your search

@@ -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;
+14
View File
@@ -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[]
@@ -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 });
});
});
@@ -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<typeof doctorReportPrefsSchema>;
/**
* 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 };
}