Skip to content
80 changes: 74 additions & 6 deletions scripts/publish-site-content-first-catalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` 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 <path>` never import or call a live Supabase
* client. Live execution requires BOTH `--execute` and `ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT=true`.
*
* USAGE
Expand All @@ -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";
Expand All @@ -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 = {
Expand All @@ -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;
Expand Down Expand Up @@ -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 = {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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<string, ClinicalSignOffEntry>();
for (const entry of serviceReviews) {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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}`,
);
}

Expand Down Expand Up @@ -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) {
Expand Down
84 changes: 82 additions & 2 deletions src/components/calculators/clinical-math-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActiveTab>(defaultTab);

Expand All @@ -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<typeof useClinicalMathPanelState>;

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);
Expand Down
7 changes: 4 additions & 3 deletions src/components/calculators/search-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -605,14 +606,14 @@ export function CalculatorsSearchPage({
sidebar={
<>
<ContinuePanel inProgress={inProgress} onOpen={openCalculator} />
{isDesktopSidebar ? <ClinicalMathPanel /> : null}
{isDesktopSidebar ? <ClinicalMathPanel state={clinicalMath} /> : null}
<AboutPanel />
</>
}
sidebarMobile={
<div className="grid gap-4 xl:hidden">
<ContinuePanel inProgress={inProgress} onOpen={openCalculator} />
{!isDesktopSidebar ? <ClinicalMathPanel /> : null}
{!isDesktopSidebar ? <ClinicalMathPanel state={clinicalMath} /> : null}
<AboutPanel />
</div>
}
Expand Down
64 changes: 64 additions & 0 deletions tests/clinical-math-panel-breakpoint.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CalculatorsSearchPage />);

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);
});
});
Loading
Loading