mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(schema): User.doctorReportPrefsJson + migration 0045
This commit is contained in:
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;
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user