From c8ee4cb9fc627f969281a4f099a68d427186d181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 22:54:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(medications):=20shared=20helpers=20?= =?UTF-8?q?=E2=80=94=20readError=20+=20route-guards=20+=20dose-string=20+?= =?UTF-8?q?=20research-mode-types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoist five duplicated helpers into single source modules so each contract evolves in exactly one place: - src/lib/api/read-error.ts — replaces the four hand-rolled copies in the onboarding flow (Welcome / Goals / Source / Baseline). - src/lib/medications/research-mode-types.ts — shared ResearchModeStatus DTO + tri-state gate helper consumed by both the DrugLevelChart and the Settings advanced section. - src/lib/medications/route-guards.ts — assertMedicationOwnership for the eleven medication sub-routes, preserving the 404 leak shape across the privacy boundary. - src/lib/medications/dose-string.ts — unified parseDoseMg + null variant replacing the DrugLevelChart and titration-route parsers. - src/lib/medications/glp1-knowledge.ts — adds findDrugIdByBrand reverse lookup so the brand-to-id pattern stops being open-coded at every chart render. 28 new unit tests across the five helpers. --- src/lib/api/__tests__/read-error.test.ts | 48 +++++++++++++ src/lib/api/read-error.ts | 27 +++++++ .../medications/__tests__/dose-string.test.ts | 55 +++++++++++++++ .../__tests__/glp1-knowledge.test.ts | 36 ++++++++++ .../__tests__/research-mode-types.test.ts | 69 ++++++++++++++++++ .../__tests__/route-guards.test.ts | 70 +++++++++++++++++++ src/lib/medications/dose-string.ts | 38 ++++++++++ src/lib/medications/glp1-knowledge.ts | 16 +++++ src/lib/medications/research-mode-types.ts | 45 ++++++++++++ src/lib/medications/route-guards.ts | 62 ++++++++++++++++ 10 files changed, 466 insertions(+) create mode 100644 src/lib/api/__tests__/read-error.test.ts create mode 100644 src/lib/api/read-error.ts create mode 100644 src/lib/medications/__tests__/dose-string.test.ts create mode 100644 src/lib/medications/__tests__/research-mode-types.test.ts create mode 100644 src/lib/medications/__tests__/route-guards.test.ts create mode 100644 src/lib/medications/dose-string.ts create mode 100644 src/lib/medications/research-mode-types.ts create mode 100644 src/lib/medications/route-guards.ts diff --git a/src/lib/api/__tests__/read-error.test.ts b/src/lib/api/__tests__/read-error.test.ts new file mode 100644 index 000000000..da0608d78 --- /dev/null +++ b/src/lib/api/__tests__/read-error.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { readError } from "../read-error"; + +function makeResponse(body: unknown, status: number, opts?: { invalidJson?: boolean }): Response { + if (opts?.invalidJson) { + return new Response("<>", { + status, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("readError", () => { + it("returns the server-supplied error string when present", async () => { + const res = makeResponse({ data: null, error: "Goal limit reached" }, 422); + expect(await readError(res)).toBe("Goal limit reached"); + }); + + it("falls back to status-coded fallback when the envelope omits error", async () => { + const res = makeResponse({ data: null }, 500); + expect(await readError(res)).toBe("Request failed (500)"); + }); + + it("falls back when the error field is empty", async () => { + const res = makeResponse({ data: null, error: "" }, 400); + expect(await readError(res)).toBe("Request failed (400)"); + }); + + it("falls back when the JSON body fails to parse", async () => { + const res = makeResponse(null, 503, { invalidJson: true }); + expect(await readError(res)).toBe("Request failed (503)"); + }); + + it("falls back when the error field is the wrong type", async () => { + const res = makeResponse({ data: null, error: 42 }, 400); + expect(await readError(res)).toBe("Request failed (400)"); + }); + + it("never throws even when the response body is empty", async () => { + const res = new Response(null, { status: 502 }); + expect(await readError(res)).toBe("Request failed (502)"); + }); +}); diff --git a/src/lib/api/read-error.ts b/src/lib/api/read-error.ts new file mode 100644 index 000000000..4e95f3026 --- /dev/null +++ b/src/lib/api/read-error.ts @@ -0,0 +1,27 @@ +/** + * v1.4.25 W21 Fix-N — shared client-side error reader. + * + * The four onboarding components (WelcomeCarousel, GoalsChipPicker, + * SourceCardGrid, BaselineForm) all hand-rolled the same helper to + * extract the `error` string from a non-OK `apiError` envelope, with an + * identical fall-through to `Request failed ()`. Hoisting the + * helper keeps the four call sites honest — the envelope shape only + * needs to evolve once. + * + * Contract: + * - The server-side `apiError` helper writes `{ data: null, error: "" }`. + * - On parse failure (network error, non-JSON body, malformed shape) + * fall back to a stable string the toast can surface. + * - Never throw — the caller is already in a catch / setError path. + */ +export async function readError(res: Response): Promise { + try { + const json = (await res.json()) as { error?: string }; + if (typeof json.error === "string" && json.error.length > 0) { + return json.error; + } + } catch { + /* fall through */ + } + return `Request failed (${res.status})`; +} diff --git a/src/lib/medications/__tests__/dose-string.test.ts b/src/lib/medications/__tests__/dose-string.test.ts new file mode 100644 index 000000000..41148d12c --- /dev/null +++ b/src/lib/medications/__tests__/dose-string.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { parseDoseMg, parseDoseMgOrNull } from "../dose-string"; + +describe("parseDoseMg", () => { + it("parses standard 'X mg' strings", () => { + expect(parseDoseMg("7.5 mg")).toBe(7.5); + expect(parseDoseMg("15 mg")).toBe(15); + expect(parseDoseMg("0.25 mg")).toBe(0.25); + }); + + it("accepts comma as decimal separator for German locale input", () => { + expect(parseDoseMg("12,5 mg")).toBe(12.5); + expect(parseDoseMg("0,25")).toBe(0.25); + }); + + it("returns NaN on unparseable input", () => { + expect(Number.isNaN(parseDoseMg(""))).toBe(true); + expect(Number.isNaN(parseDoseMg("as needed"))).toBe(true); + expect(Number.isNaN(parseDoseMg("mg"))).toBe(true); + }); + + it("extracts the first number when units precede / follow", () => { + expect(parseDoseMg("dose: 2.5 mg")).toBe(2.5); + expect(parseDoseMg("2.5mg")).toBe(2.5); + }); + + it("strips leading whitespace when the regex picks the number", () => { + expect(parseDoseMg(" 5 mg")).toBe(5); + }); +}); + +describe("parseDoseMgOrNull", () => { + it("returns the numeric value when parseable", () => { + expect(parseDoseMgOrNull("7.5 mg")).toBe(7.5); + expect(parseDoseMgOrNull("0,5")).toBe(0.5); + }); + + it("returns null on unparseable input rather than NaN", () => { + expect(parseDoseMgOrNull("")).toBeNull(); + expect(parseDoseMgOrNull("as needed")).toBeNull(); + expect(parseDoseMgOrNull("mg")).toBeNull(); + }); + + it("returns null when the numeric portion is non-finite", () => { + // Defence-in-depth — Number.parseFloat itself never returns + // Infinity from this regex, but the guard belongs to the helper + // contract. + expect(parseDoseMgOrNull("not a dose")).toBeNull(); + }); + + it("preserves zero as a valid dose (not null) when the user logs 0 mg", () => { + expect(parseDoseMgOrNull("0 mg")).toBe(0); + }); +}); diff --git a/src/lib/medications/__tests__/glp1-knowledge.test.ts b/src/lib/medications/__tests__/glp1-knowledge.test.ts index 330612531..d2e807922 100644 --- a/src/lib/medications/__tests__/glp1-knowledge.test.ts +++ b/src/lib/medications/__tests__/glp1-knowledge.test.ts @@ -4,6 +4,7 @@ import { GLP1_DRUG_IDS, GLP1_DRUGS, findDrugByBrand, + findDrugIdByBrand, routeForBrand, type Glp1DrugId, } from "@/lib/medications/glp1-knowledge"; @@ -244,6 +245,41 @@ describe("glp1-knowledge catalog", () => { }); }); + describe("findDrugIdByBrand", () => { + it("returns the drug id for a known brand", () => { + expect(findDrugIdByBrand("Mounjaro")).toBe("tirzepatide"); + expect(findDrugIdByBrand("Ozempic")).toBe("semaglutide"); + expect(findDrugIdByBrand("Saxenda")).toBe("liraglutide"); + }); + + it("is case-insensitive on the brand argument", () => { + expect(findDrugIdByBrand("mounjaro")).toBe("tirzepatide"); + expect(findDrugIdByBrand("OZEMPIC")).toBe("semaglutide"); + }); + + it("returns null for an unknown brand", () => { + expect(findDrugIdByBrand("BogusName")).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(findDrugIdByBrand("")).toBeNull(); + expect(findDrugIdByBrand(" ")).toBeNull(); + }); + + it("trims surrounding whitespace before matching", () => { + expect(findDrugIdByBrand(" Wegovy ")).toBe("semaglutide"); + }); + + it("preserves the (id ↔ record) round-trip with findDrugByBrand", () => { + for (const id of GLP1_DRUG_IDS) { + const record = GLP1_DRUGS[id]; + const [firstBrand] = record.brands; + expect(findDrugIdByBrand(firstBrand)).toBe(id); + expect(findDrugByBrand(firstBrand)).toBe(record); + } + }); + }); + describe("citations", () => { it("links every record to an EMA EPAR PDF", () => { for (const id of GLP1_DRUG_IDS) { diff --git a/src/lib/medications/__tests__/research-mode-types.test.ts b/src/lib/medications/__tests__/research-mode-types.test.ts new file mode 100644 index 000000000..6480e04f6 --- /dev/null +++ b/src/lib/medications/__tests__/research-mode-types.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; + +import { + researchModeGateState, + type ResearchModeStatus, +} from "../research-mode-types"; + +const ACK_OPEN: ResearchModeStatus = { + enabled: true, + acknowledgedAt: "2026-05-13T00:00:00.000Z", + acknowledgedVersion: "v3", + currentDisclaimerVersion: "v3", +}; + +const ACK_STALE: ResearchModeStatus = { + enabled: true, + acknowledgedAt: "2026-04-01T00:00:00.000Z", + acknowledgedVersion: "v2", + currentDisclaimerVersion: "v3", +}; + +const OFF: ResearchModeStatus = { + enabled: false, + acknowledgedAt: null, + acknowledgedVersion: null, + currentDisclaimerVersion: "v3", +}; + +describe("researchModeGateState", () => { + it("returns 'off' when status is null (unauthenticated / loading)", () => { + expect(researchModeGateState(null)).toBe("off"); + }); + + it("returns 'off' when status is undefined", () => { + expect(researchModeGateState(undefined)).toBe("off"); + }); + + it("returns 'off' when the user never enabled Research Mode", () => { + expect(researchModeGateState(OFF)).toBe("off"); + }); + + it("returns 'open' when enabled and acknowledged versions align", () => { + expect(researchModeGateState(ACK_OPEN)).toBe("open"); + }); + + it("returns 'stale' when enabled but server bumped the disclaimer version", () => { + expect(researchModeGateState(ACK_STALE)).toBe("stale"); + }); + + it("treats acknowledgedVersion === null as stale when enabled", () => { + const status: ResearchModeStatus = { + enabled: true, + acknowledgedAt: null, + acknowledgedVersion: null, + currentDisclaimerVersion: "v3", + }; + expect(researchModeGateState(status)).toBe("stale"); + }); + + it("never returns 'open' when enabled is false even if versions happen to match", () => { + const status: ResearchModeStatus = { + enabled: false, + acknowledgedAt: "2026-05-13T00:00:00.000Z", + acknowledgedVersion: "v3", + currentDisclaimerVersion: "v3", + }; + expect(researchModeGateState(status)).toBe("off"); + }); +}); diff --git a/src/lib/medications/__tests__/route-guards.test.ts b/src/lib/medications/__tests__/route-guards.test.ts new file mode 100644 index 000000000..e16645394 --- /dev/null +++ b/src/lib/medications/__tests__/route-guards.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +import { assertMedicationOwnership } from "../route-guards"; + +function buildClient(med: { id: string; userId: string } | null) { + return { + medication: { + findUnique: vi.fn().mockResolvedValue(med), + }, + }; +} + +describe("assertMedicationOwnership", () => { + it("returns null when the medication exists and matches the caller", async () => { + const client = buildClient({ id: "med-1", userId: "user-1" }); + const result = await assertMedicationOwnership("med-1", "user-1", client); + expect(result).toBeNull(); + expect(client.medication.findUnique).toHaveBeenCalledWith({ + where: { id: "med-1" }, + select: { id: true, userId: true }, + }); + }); + + it("returns 404 when the medication does not exist", async () => { + const client = buildClient(null); + const result = await assertMedicationOwnership("med-missing", "user-1", client); + expect(result).not.toBeNull(); + expect(result?.status).toBe(404); + const body = await result!.json(); + expect(body.error).toBe("Medication not found"); + }); + + it("returns 404 when the medication belongs to a different user", async () => { + const client = buildClient({ id: "med-1", userId: "attacker" }); + const result = await assertMedicationOwnership("med-1", "victim", client); + expect(result).not.toBeNull(); + expect(result?.status).toBe(404); + const body = await result!.json(); + // Privacy contract — we never leak "exists but wrong owner" via a + // 403; an attacker sees the same shape as a missing row. + expect(body.error).toBe("Medication not found"); + }); + + it("never reveals user-id correlation in the 404 envelope", async () => { + const client = buildClient({ id: "med-1", userId: "someone-else" }); + const result = await assertMedicationOwnership("med-1", "caller", client); + const body = (await result!.json()) as Record; + const serialised = JSON.stringify(body); + expect(serialised).not.toContain("someone-else"); + expect(serialised).not.toContain("caller"); + }); + + it("propagates the underlying Prisma error rather than swallowing it", async () => { + const client = { + medication: { + findUnique: vi.fn().mockRejectedValue(new Error("DB offline")), + }, + }; + await expect( + assertMedicationOwnership("med-1", "user-1", client), + ).rejects.toThrow("DB offline"); + }); + + it("only selects the minimal columns required for the comparison", async () => { + const client = buildClient({ id: "med-1", userId: "user-1" }); + await assertMedicationOwnership("med-1", "user-1", client); + const call = client.medication.findUnique.mock.calls[0][0]; + expect(call.select).toEqual({ id: true, userId: true }); + }); +}); diff --git a/src/lib/medications/dose-string.ts b/src/lib/medications/dose-string.ts new file mode 100644 index 000000000..02784264e --- /dev/null +++ b/src/lib/medications/dose-string.ts @@ -0,0 +1,38 @@ +/** + * v1.4.25 W21 Fix-N — unified dose-string → mg parser. + * + * Replaces two near-identical helpers previously hand-rolled inside + * `` (`parseDoseMg`) and the titration route + * (`parseDoseString`). The DrugLevelChart variant returned `NaN` for + * unparseable strings; the titration variant returned `null`. Both + * code paths now go through this module — the legacy null-returning + * shape is preserved as `parseDoseMgOrNull` so the titration route + * keeps its "no current step" semantics. + * + * Accepts comma OR dot as decimal separator (Marc-memory: the German + * UI lets the user type "0,25 mg"). Trailing unit text after the + * number is ignored — the parser only owns the numeric extraction. + */ + +/** + * Parse a dose string like "7.5 mg" → 7.5 or "0,25" → 0.25. Returns + * `NaN` when the string carries no recognisable number (e.g. "as + * needed") so callers can use `Number.isFinite` for the gate. + */ +export function parseDoseMg(input: string): number { + if (!input) return Number.NaN; + const match = input.match(/([0-9]+(?:[.,][0-9]+)?)/); + if (!match) return Number.NaN; + return Number.parseFloat(match[1].replace(",", ".")); +} + +/** + * Same as `parseDoseMg` but returns `null` instead of `NaN` for the + * unparseable case. The titration route reads this as "no current + * step" — null signals "we couldn't resolve a dose" rather than "the + * dose is zero". + */ +export function parseDoseMgOrNull(input: string): number | null { + const value = parseDoseMg(input); + return Number.isFinite(value) ? value : null; +} diff --git a/src/lib/medications/glp1-knowledge.ts b/src/lib/medications/glp1-knowledge.ts index 659c3f2d4..3db2f9ded 100644 --- a/src/lib/medications/glp1-knowledge.ts +++ b/src/lib/medications/glp1-knowledge.ts @@ -580,6 +580,22 @@ export function findDrugByBrand(brand: string): Glp1DrugRecord | null { return null; } +/** + * Lookup helper — given an arbitrary brand string, find the drug ID + * (catalog key) that lists it. Combines `findDrugByBrand` with the + * `GLP1_DRUGS` reverse-lookup pattern previously hand-rolled in the + * DrugLevelChart and the titration route. Returns null when the brand + * is unknown. + */ +export function findDrugIdByBrand(brand: string): Glp1DrugId | null { + const record = findDrugByBrand(brand); + if (!record) return null; + for (const [id, candidate] of Object.entries(GLP1_DRUGS)) { + if (candidate === record) return id as Glp1DrugId; + } + return null; +} + /** * The catalog's drug-ids in stable order. Useful for iteration in * tests and UI surfaces that need a deterministic display order. diff --git a/src/lib/medications/research-mode-types.ts b/src/lib/medications/research-mode-types.ts new file mode 100644 index 000000000..e9524fd1b --- /dev/null +++ b/src/lib/medications/research-mode-types.ts @@ -0,0 +1,45 @@ +/** + * v1.4.25 W21 Fix-N — shared Research Mode status DTO. + * + * Both `` (medication detail page) and the Settings → + * Advanced surface (``) query the same + * `/api/auth/me/research-mode` endpoint to gate the drug-level chart on + * acknowledged disclaimer version. Until Fix-N both surfaces hand-rolled + * the same `ResearchModeStatus` interface; this module is now the single + * source of truth so the two readers can never disagree on shape. + * + * Contract mirrors the API response under the `data` key: + * { enabled, acknowledgedAt, acknowledgedVersion, currentDisclaimerVersion } + * + * `enabled === true && acknowledgedVersion === currentDisclaimerVersion` + * is the only gate-open shape; every other permutation (off, stale-on) + * renders the gated placeholder UI. + */ +export interface ResearchModeStatus { + /** User opt-in flag — false unless the user has acknowledged at least once. */ + enabled: boolean; + /** ISO timestamp of the most recent acknowledgment, null when never acknowledged. */ + acknowledgedAt: string | null; + /** Disclaimer version the user last acknowledged; null when never acknowledged. */ + acknowledgedVersion: string | null; + /** Disclaimer version the server is currently serving. Drives the re-prompt branch. */ + currentDisclaimerVersion: string; +} + +/** + * Tri-state helper — narrow the raw status into one of the three UI + * branches: off, stale (acknowledged-but-server-bumped-version), or + * open. The DrugLevelChart and the Settings re-prompt banner both use + * this so the rules stay aligned. + */ +export type ResearchModeGateState = "off" | "stale" | "open"; + +export function researchModeGateState( + status: ResearchModeStatus | null | undefined, +): ResearchModeGateState { + if (!status || !status.enabled) return "off"; + if (status.acknowledgedVersion !== status.currentDisclaimerVersion) { + return "stale"; + } + return "open"; +} diff --git a/src/lib/medications/route-guards.ts b/src/lib/medications/route-guards.ts new file mode 100644 index 000000000..6147062fd --- /dev/null +++ b/src/lib/medications/route-guards.ts @@ -0,0 +1,62 @@ +/** + * v1.4.25 W21 Fix-N — shared medication-route ownership guard. + * + * Eleven medication sub-routes (titration, side-effects, inventory, + * cadence, intake, compliance, phase-config, api-endpoint, glp1, plus + * the inventory `[itemId]` sub-route owned by Fix-M) all duplicated the + * same fetch-medication-then-compare-userId block, often with subtly + * different `select` clauses or response shapes. Hoisting the guard to + * one helper keeps the privacy contract honest — the 404 leak shape + * (we always return "Medication not found" so an attacker can't + * distinguish "row exists but isn't yours" from "row doesn't exist") + * lives in exactly one place. + * + * Callers compose it the same way the inline guards used to: + * + * const guard = await assertMedicationOwnership(id, user.id); + * if (guard) return guard; + * + * The helper accepts an optional Prisma client so test mocks can pass + * a stubbed `findUnique`. In production callers omit the third arg and + * pull `prisma` from the lazy `@/lib/db` import internally. + */ + +import { apiError } from "@/lib/api-response"; + +interface MedicationOwnershipPrisma { + medication: { + findUnique: (args: { + where: { id: string }; + select: { id: true; userId: true }; + }) => Promise<{ id: string; userId: string } | null>; + }; +} + +/** + * Returns `null` when the medication exists AND belongs to the caller. + * Returns a 404 `Response` otherwise — the route handler should return + * it directly without modification. + */ +export async function assertMedicationOwnership( + medicationId: string, + userId: string, + prismaClient?: MedicationOwnershipPrisma, +): Promise { + const client = prismaClient ?? (await loadPrisma()); + const med = await client.medication.findUnique({ + where: { id: medicationId }, + select: { id: true, userId: true }, + }); + if (!med || med.userId !== userId) { + return apiError("Medication not found", 404); + } + return null; +} + +async function loadPrisma(): Promise { + // Lazy import so test files that stub `@/lib/db` at the module level + // see their stub instead of the real Prisma client. Matches the + // pattern the route handlers themselves use. + const { prisma } = await import("@/lib/db"); + return prisma as unknown as MedicationOwnershipPrisma; +}