diff --git a/scripts/publish-site-content-first-catalogue.ts b/scripts/publish-site-content-first-catalogue.ts index 0080d13a7d..808d8a0a89 100644 --- a/scripts/publish-site-content-first-catalogue.ts +++ b/scripts/publish-site-content-first-catalogue.ts @@ -14,7 +14,10 @@ * 1. Fail-closed clinical sign-off check: every record included in the plan must carry * `status === "reviewed"`, a named human reviewer (`isNamedPerson(reviewedBy)` — rejecting * `"PsychSift"`, `"System"`, bare titles, etc.), and a valid UTC `reviewedAt` timestamp. - * 2. Offline by default: `--dry-run` and `--out ` never import or call a live Supabase + * 2. Content pin check: each sign-off's `reviewedContentSha256` must still match the content it + * signed, worked out the same way npm run clinical:review wrote it. A record edited after its + * sign-off is refused, so a stale approval can never publish changed text. + * 3. Offline by default: `--dry-run` and `--out ` never import or call a live Supabase * client. Live execution requires BOTH `--execute` and `ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT=true`. * * USAGE @@ -24,8 +27,8 @@ */ import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { isNamedPerson } from "@/lib/admin/rule-sign-off"; import { formRecords, type FormRecord } from "@/lib/forms"; @@ -44,6 +47,9 @@ import { import { compareCanonicalSiteContentIdentifiers, siteContentValueHash } from "@/lib/site-content/site-content-manifest"; import { reconciliationHash } from "@/lib/site-content/site-content-reconciliation"; +import { recordPinState } from "./lib/clinical-record-review-contract.mjs"; +import { loadContext as loadServiceSignOffContext } from "./lib/signoff-kinds/services.mjs"; + export type FirstPublicationKind = "service" | "form" | "all"; export type ClinicalSignOffEntry = { @@ -56,6 +62,14 @@ export type ClinicalSignOffEntry = { reviewedContentSha256?: string | null; }; +/** + * Whether a sign-off's content pin still matches what it signed. `unverifiable` means the + * content the pin covers could not be loaded, which is refused like a stale pin. + */ +export type SignOffPinState = "current" | "stale" | "unsigned" | "unverifiable"; + +export type SignOffPinCheck = (kind: "service" | "form", review: ClinicalSignOffEntry) => SignOffPinState; + export type FirstPublicationRejectedRecord = { kind: "service" | "form"; recordId: string; @@ -94,6 +108,10 @@ export type BuildFirstPublicationOptions = { serviceReviews?: readonly ClinicalSignOffEntry[]; formReviews?: readonly ClinicalSignOffEntry[]; onlySlugs?: readonly string[]; + /** Checks each content pin. Defaults to the clinical:review contract over the repository data. */ + pinState?: SignOffPinCheck; + /** The curated service records a service pin covers (from the services sign-off kind). */ + serviceSignOffContext?: object; }; export type FirstPublicationCliOptions = { @@ -106,6 +124,8 @@ export type FirstPublicationCliOptions = { formsReviewPath: string; }; +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; function isValidReviewTimestamp(value: string | null | undefined): boolean { @@ -118,11 +138,27 @@ function isValidReviewTimestamp(value: string | null | undefined): boolean { return normalized.slice(0, 19) === trimmed.slice(0, 19); } +/** The default pin check: the same digest npm run clinical:review wrote at sign-off. */ +export function createSignOffPinCheck( + options: { serviceContext?: object; formsCatalogPath?: string } = {}, +): SignOffPinCheck { + let formsCatalog: unknown; + return (kind, review) => { + if (kind === "service") { + if (!options.serviceContext) return "unverifiable"; + return recordPinState(review, "service", options.serviceContext); + } + formsCatalog ??= JSON.parse(readFileSync(resolve(options.formsCatalogPath ?? "data/forms-catalog.json"), "utf8")); + return recordPinState(review, "form", { catalog: formsCatalog }); + }; +} + function validateSignOffEntry( kind: "service" | "form", recordId: string, slug: string, review: ClinicalSignOffEntry | undefined, + pinState: SignOffPinCheck, ): FirstPublicationRejectedRecord | null { if (!review) { return { @@ -164,6 +200,35 @@ function validateSignOffEntry( reason: `reviewedContentSha256 ${JSON.stringify(review.reviewedContentSha256)} is missing or not a valid 64-character hex SHA-256 digest`, }; } + // The pin covers the record its own id (service) or code (form) names, so that must be the + // record being published, not one the review was only matched to through a slug alias. + const reviewIdentity = kind === "service" ? review.id : review.code; + if (typeof reviewIdentity !== "string" || reviewIdentity.trim().toLowerCase() !== recordId.trim().toLowerCase()) { + return { + kind, + recordId, + slug, + reason: `its review names ${JSON.stringify(reviewIdentity ?? null)}, not this record, so its sign-off does not cover it`, + }; + } + const pin = pinState(kind, review); + if (pin === "unverifiable") { + return { + kind, + recordId, + slug, + reason: "the content its sign-off covers could not be loaded, so the content pin could not be checked", + }; + } + if (pin !== "current") { + return { + kind, + recordId, + slug, + reason: + "content changed since sign-off (reviewedContentSha256 no longer matches); re-review it with npm run clinical:review", + }; + } return null; } @@ -209,6 +274,7 @@ export function buildFirstPublicationCataloguePlan( const serviceReviews = options.serviceReviews ?? loadDefaultServiceReviews(); const formReviews = options.formReviews ?? loadDefaultFormReviews(); + const pinState = options.pinState ?? createSignOffPinCheck({ serviceContext: options.serviceSignOffContext }); const serviceReviewByKey = new Map(); for (const entry of serviceReviews) { @@ -238,7 +304,7 @@ export function buildFirstPublicationCataloguePlan( serviceReviewByKey.get(stableId.toLowerCase()) ?? serviceReviewByKey.get(service.slug.trim().toLowerCase()) ?? (serviceWithId.id ? serviceReviewByKey.get(serviceWithId.id.trim().toLowerCase()) : undefined); - const problem = validateSignOffEntry("service", stableId, service.slug, review); + const problem = validateSignOffEntry("service", stableId, service.slug, review, pinState); if (problem) { rejected.push(problem); continue; @@ -263,7 +329,7 @@ export function buildFirstPublicationCataloguePlan( formReviewByKey.get(formCode.toLowerCase()) ?? formReviewByKey.get(form.slug.trim().toLowerCase()) ?? formReviewByKey.get(formRecordId.toLowerCase()); - const problem = validateSignOffEntry("form", formCode, form.slug, review); + const problem = validateSignOffEntry("form", formCode, form.slug, review, pinState); if (problem) { rejected.push(problem); continue; @@ -278,7 +344,7 @@ export function buildFirstPublicationCataloguePlan( .join("\n"); const remainder = rejected.length > 10 ? `\n ...and ${rejected.length - 10} more record(s).` : ""; throw new Error( - `First-publication plan refused: ${rejected.length} record(s) lack clinical sign-off by a named person:\n${preview}${remainder}`, + `First-publication plan refused: ${rejected.length} record(s) lack a current clinical sign-off by a named person:\n${preview}${remainder}`, ); } @@ -440,6 +506,8 @@ export async function runFirstPublicationCli( batchSize: options.batchSize, serviceReviews: loadDefaultServiceReviews(options.servicesReviewPath), formReviews: loadDefaultFormReviews(options.formsReviewPath), + serviceSignOffContext: + options.kind === "form" ? undefined : await loadServiceSignOffContext("service", REPOSITORY_ROOT), }); if (options.out) { diff --git a/src/components/calculators/clinical-math-panel.tsx b/src/components/calculators/clinical-math-panel.tsx index 76c55a5635..fe5733e793 100644 --- a/src/components/calculators/clinical-math-panel.tsx +++ b/src/components/calculators/clinical-math-panel.tsx @@ -30,8 +30,12 @@ function parseStrictNumber(value: string): number { return trimmed === "" ? Number.NaN : Number(trimmed); } -export function ClinicalMathPanel({ defaultOpen = false, defaultTab = "renal" }: ClinicalMathPanelProps) { - const panelId = useId(); +/** + * Everything the clinician has entered. The calculator page keeps one copy and hands it to + * whichever panel is mounted, so crossing the desktop breakpoint (the panel moves between the + * sidebar and the mobile column) does not wipe the entries. It lives in memory only. + */ +export function useClinicalMathPanelState({ defaultOpen = false, defaultTab = "renal" }: ClinicalMathPanelProps = {}) { const [open, setOpen] = useState(defaultOpen); const [activeTab, setActiveTab] = useState(defaultTab); @@ -54,6 +58,82 @@ export function ClinicalMathPanel({ defaultOpen = false, defaultTab = "renal" }: const [targetLevel, setTargetLevel] = useState(""); const [tabletIncrementMg, setTabletIncrementMg] = useState<125 | 225 | 250 | 450>(125); + return { + open, + setOpen, + activeTab, + setActiveTab, + age, + setAge, + sex, + setSex, + weightKg, + setWeightKg, + heightCm, + setHeightCm, + creatinine, + setCreatinine, + creatinineUnit, + setCreatinineUnit, + qtMs, + setQtMs, + hrBpm, + setHrBpm, + lithiumLevel, + setLithiumLevel, + lithiumIndication, + setLithiumIndication, + currentDoseMg, + setCurrentDoseMg, + targetLevel, + setTargetLevel, + tabletIncrementMg, + setTabletIncrementMg, + }; +} + +export type ClinicalMathPanelState = ReturnType; + +export function ClinicalMathPanel({ + defaultOpen = false, + defaultTab = "renal", + state, +}: ClinicalMathPanelProps & { state?: ClinicalMathPanelState }) { + const panelId = useId(); + const ownState = useClinicalMathPanelState({ defaultOpen, defaultTab }); + const { + open, + setOpen, + activeTab, + setActiveTab, + age, + setAge, + sex, + setSex, + weightKg, + setWeightKg, + heightCm, + setHeightCm, + creatinine, + setCreatinine, + creatinineUnit, + setCreatinineUnit, + qtMs, + setQtMs, + hrBpm, + setHrBpm, + lithiumLevel, + setLithiumLevel, + lithiumIndication, + setLithiumIndication, + currentDoseMg, + setCurrentDoseMg, + targetLevel, + setTargetLevel, + tabletIncrementMg, + setTabletIncrementMg, + } = state ?? ownState; + const parsedAge = parseStrictNumber(age); const parsedWeight = parseStrictNumber(weightKg); const parsedHeight = heightCm.trim() === "" ? undefined : parseStrictNumber(heightCm); diff --git a/src/components/calculators/search-page.tsx b/src/components/calculators/search-page.tsx index 38b02e48d0..16587ff2cb 100644 --- a/src/components/calculators/search-page.tsx +++ b/src/components/calculators/search-page.tsx @@ -45,7 +45,7 @@ import { type CalculatorFixture, } from "./calculator-fixtures"; import { CalculatorSheet } from "./calculator-sheet"; -import { ClinicalMathPanel } from "./clinical-math-panel"; +import { ClinicalMathPanel, useClinicalMathPanelState } from "./clinical-math-panel"; import { calculatorRecordById } from "./calculator-routes"; import { MetaPill, @@ -345,6 +345,7 @@ export function CalculatorsSearchPage({ () => false, ); const isDesktopSidebar = useSyncExternalStore(subscribeDesktopSidebar, getDesktopSidebarSnapshot, () => false); + const clinicalMath = useClinicalMathPanelState(); const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery; const normalizedQuery = normalizeCalculatorQuery(query); const smartExpansions = useMemo(() => smartSearchExpansions("calculators", query), [query]); @@ -605,14 +606,14 @@ export function CalculatorsSearchPage({ sidebar={ <> - {isDesktopSidebar ? : null} + {isDesktopSidebar ? : null} } sidebarMobile={
- {!isDesktopSidebar ? : null} + {!isDesktopSidebar ? : null}
} diff --git a/tests/clinical-math-panel-breakpoint.dom.test.tsx b/tests/clinical-math-panel-breakpoint.dom.test.tsx new file mode 100644 index 0000000000..424ed93c65 --- /dev/null +++ b/tests/clinical-math-panel-breakpoint.dom.test.tsx @@ -0,0 +1,64 @@ +/** @vitest-environment jsdom */ + +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/components/clinical-dashboard/universal-search-also-matches", () => ({ + UniversalSearchAlsoMatches: () => null, +})); + +import { CalculatorsSearchPage } from "@/components/calculators/search-page"; + +// A controllable (min-width: 1280px) query, so the test can cross the desktop breakpoint. +function installDesktopQuery(initial: boolean) { + let matches = initial; + const listeners = new Set<() => void>(); + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + media: query, + get matches() { + return query === "(min-width: 1280px)" ? matches : false; + }, + addEventListener: (_type: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener), + addListener: (listener: () => void) => listeners.add(listener), + removeListener: (listener: () => void) => listeners.delete(listener), + onchange: null, + dispatchEvent: () => true, + })), + ); + return (next: boolean) => { + matches = next; + act(() => { + for (const listener of listeners) listener(); + }); + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("bedside calculator panel across the desktop breakpoint", () => { + it("keeps the clinician's entries when the panel moves between the sidebar and the mobile column", () => { + const setDesktop = installDesktopQuery(false); + render(); + + fireEvent.click(screen.getByTestId("clinical-math-panel-toggle")); + fireEvent.change(screen.getByLabelText("Age (years)"), { target: { value: "72" } }); + fireEvent.change(screen.getByLabelText("Weight (kg)"), { target: { value: "64" } }); + + setDesktop(true); + expect(screen.getAllByTestId("clinical-math-panel-toggle")).toHaveLength(1); + expect(screen.getByLabelText("Age (years)")).toHaveValue(72); + expect(screen.getByLabelText("Weight (kg)")).toHaveValue(64); + + setDesktop(false); + expect(screen.getByLabelText("Age (years)")).toHaveValue(72); + }); +}); diff --git a/tests/site-content-first-publication-cli.test.ts b/tests/site-content-first-publication-cli.test.ts index 57d91d3d1a..903a471a20 100644 --- a/tests/site-content-first-publication-cli.test.ts +++ b/tests/site-content-first-publication-cli.test.ts @@ -1,3 +1,6 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + import { describe, expect, it } from "vitest"; import { formRecords } from "@/lib/forms"; @@ -7,12 +10,35 @@ import { parseFirstPublicationCliArgs, runFirstPublicationCli, type ClinicalSignOffEntry, + type SignOffPinCheck, } from "../scripts/publish-site-content-first-catalogue"; const NAMED_REVIEWER = "Dr Jane Citizen"; const REVIEWED_AT = "2026-10-16T01:00:00.000Z"; const REVIEWED_SHA256 = "eb950d3c48dba095ef61d7b0645709d90316ed475b5eb6602b670e62e6e07ad1"; +// A distinct digest per record, so the plan check below proves each record is matched to +// its own sign-off, not to one shared pin. +function fixtureDigest(kind: string, key: string): string { + return createHash("sha256").update(`${kind}:${key}`).digest("hex"); +} + +const fixturePinCheck: SignOffPinCheck = (kind, review) => + review.reviewedContentSha256 === fixtureDigest(kind, String(kind === "form" ? review.code : review.id)) + ? "current" + : "stale"; + +type RepositoryFormReview = ClinicalSignOffEntry & { code: string }; + +function repositoryFormReview(code: string): RepositoryFormReview { + const document = JSON.parse(readFileSync("data/forms-content-review.json", "utf8")) as { + forms: RepositoryFormReview[]; + }; + const review = document.forms.find((entry) => entry.code === code); + if (!review) throw new Error(`No repository review for Form ${code}.`); + return review; +} + function formCodeForRecord(form: (typeof formRecords)[number]): string { const payload = (form.catalogPayload ?? {}) as { form?: string }; return String(payload.form ?? form.catalogueLabel ?? form.slug) @@ -27,7 +53,7 @@ function signedServiceReviews(): ClinicalSignOffEntry[] { status: "reviewed", reviewedBy: NAMED_REVIEWER, reviewedAt: REVIEWED_AT, - reviewedContentSha256: REVIEWED_SHA256, + reviewedContentSha256: fixtureDigest("service", String(service.catalogPayload?.stableId ?? service.slug)), })); } @@ -38,7 +64,7 @@ function signedFormReviews(): ClinicalSignOffEntry[] { status: "reviewed", reviewedBy: NAMED_REVIEWER, reviewedAt: REVIEWED_AT, - reviewedContentSha256: REVIEWED_SHA256, + reviewedContentSha256: fixtureDigest("form", formCodeForRecord(form)), })); } @@ -47,7 +73,7 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { const formPlan = buildFirstPublicationCataloguePlan({ kind: "form" }); expect(formPlan.counts.forms).toBe(54); expect(() => buildFirstPublicationCataloguePlan({ kind: "service" })).toThrow( - /First-publication plan refused: \d+ record\(s\) lack clinical sign-off by a named person/, + /First-publication plan refused: \d+ record\(s\) lack a current clinical sign-off by a named person/, ); }); @@ -107,6 +133,45 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { ).toThrow(/First-publication plan refused/); }); + it("refuses a form whose content changed after its sign-off, and accepts it while the pin is current", () => { + const sampleForm = formRecords.find((form) => formCodeForRecord(form) === "1A")!; + const signed = repositoryFormReview("1A"); + expect(signed.status).toBe("reviewed"); + + const current = buildFirstPublicationCataloguePlan({ kind: "form", forms: [sampleForm], formReviews: [signed] }); + expect(current.counts.forms).toBe(1); + + // A well-formed digest that is not the one the signed content produces: the content no longer matches. + const stalePin = createHash("sha256").update("edited after sign-off").digest("hex"); + expect(() => + buildFirstPublicationCataloguePlan({ + kind: "form", + forms: [sampleForm], + formReviews: [{ ...signed, reviewedContentSha256: stalePin }], + }), + ).toThrow(/content changed since sign-off/); + }); + + it("refuses a current sign-off that reaches a different form only through a slug alias", () => { + const otherForm = formRecords.find((form) => formCodeForRecord(form) !== "1A")!; + const signed = repositoryFormReview("1A"); + expect(() => + buildFirstPublicationCataloguePlan({ + kind: "form", + forms: [otherForm], + formReviews: [{ ...signed, slug: otherForm.slug }], + }), + ).toThrow(/its review names "1A", not this record/); + }); + + it("refuses a service sign-off when the curated records it pins were not loaded", () => { + const sampleService = defaultServiceRecords()[0]!; + const [review] = signedServiceReviews(); + expect(() => + buildFirstPublicationCataloguePlan({ kind: "service", services: [sampleService], serviceReviews: [review!] }), + ).toThrow(/content pin could not be checked/); + }); + it("builds a deterministic batched first-publication plan once all 222 services and 54 forms have named clinician sign-off", () => { const services222 = defaultServiceRecords().slice(0, 222); const planA = buildFirstPublicationCataloguePlan({ @@ -115,6 +180,7 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { services: services222, serviceReviews: signedServiceReviews(), formReviews: signedFormReviews(), + pinState: fixturePinCheck, }); const planB = buildFirstPublicationCataloguePlan({ kind: "all", @@ -122,6 +188,7 @@ describe("publish-site-content-first-catalogue CLI (#HTZPQ8 / #ZPHK2F)", () => { services: services222, serviceReviews: signedServiceReviews(), formReviews: signedFormReviews(), + pinState: fixturePinCheck, }); expect(planA.version).toBe("site-content-first-publication-plan-v1");