From 957fa2f7cf66278f82aa648f874dd9ffd2ce83eb Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:22:03 +0800 Subject: [PATCH 01/15] feat(caring-contacts): harden data integrity, patient privacy, and clinician safety (#HDCF2B, #LM33K2, #Q8NMM3, #8K9W2B, #M6P1QQ, #99W2X1) - #HDCF2B: Refactor caseload search query to use POST body payloads and opaque session filter tokens with TTL expiration and automatic invalid token removal, eliminating PHI from URLs and access logs. - #LM33K2: Fix off-by-one boundary condition in caseload pagination query and normalize fractional page and page sizes. - #Q8NMM3: Extend CaringContactsAuditEntry and AuditEvent to capture actorRole signatures, updating export serialization and schema validation. - #8K9W2B: Synchronize notification delivery retry queue backoff ladder [1m, 5m, 15m, 1h, 6h], clamping attempts and handling dead-letter state. - #M6P1QQ: Add optimistic locking with version checks in draft-store to prevent stale draft overwrites, prevent resurrecting deleted drafts, and preserve clinician attempted content during conflicts. - #99W2X1: Add confirmation modal barrier with keyboard navigation and focus management before transitioning active contact plans to inactive. --- .../5e2e28b3-785e-4be2-b8dd-1781750e3ea0.json | 11 + docs/site-map.md | 1 + .../caring-contacts/patients/search/route.ts | 65 +++++ src/app/caring-contacts/patients/page.tsx | 1 + .../workspace/patients-directory-client.tsx | 5 +- .../workspace/patients-directory.tsx | 4 + .../workspace/plan-status-toggle.tsx | 233 ++++++++++++++++ src/lib/caring-contacts/audit.ts | 105 +++++++ src/lib/caring-contacts/caseload-query.ts | 127 +++++++++ .../caring-contacts/caseload-search-token.ts | 94 +++++++ .../caring-contacts/db/postgres-repository.ts | 4 +- src/lib/caring-contacts/draft-store.ts | 170 ++++++++++++ .../patients-directory-filter.ts | 27 +- src/lib/caring-contacts/retry-queue.ts | 215 +++++++++++++++ tests/caring-contacts-audit.test.ts | 117 ++++++++ tests/caring-contacts-caseload-query.test.ts | 180 ++++++++++++ tests/caring-contacts-draft-store.test.ts | 189 +++++++++++++ ...g-contacts-plan-status-toggle.dom.test.tsx | 214 +++++++++++++++ tests/caring-contacts-retry-queue.test.ts | 256 ++++++++++++++++++ tests/caring-contacts-search-privacy.test.ts | 141 ++++++++++ 20 files changed, 2154 insertions(+), 5 deletions(-) create mode 100644 docs/outstanding-issues-inbox/5e2e28b3-785e-4be2-b8dd-1781750e3ea0.json create mode 100644 src/app/api/caring-contacts/patients/search/route.ts create mode 100644 src/components/caring-contacts/workspace/plan-status-toggle.tsx create mode 100644 src/lib/caring-contacts/caseload-query.ts create mode 100644 src/lib/caring-contacts/caseload-search-token.ts create mode 100644 src/lib/caring-contacts/draft-store.ts create mode 100644 src/lib/caring-contacts/retry-queue.ts create mode 100644 tests/caring-contacts-caseload-query.test.ts create mode 100644 tests/caring-contacts-draft-store.test.ts create mode 100644 tests/caring-contacts-plan-status-toggle.dom.test.tsx create mode 100644 tests/caring-contacts-retry-queue.test.ts create mode 100644 tests/caring-contacts-search-privacy.test.ts diff --git a/docs/outstanding-issues-inbox/5e2e28b3-785e-4be2-b8dd-1781750e3ea0.json b/docs/outstanding-issues-inbox/5e2e28b3-785e-4be2-b8dd-1781750e3ea0.json new file mode 100644 index 0000000000..1c8389986f --- /dev/null +++ b/docs/outstanding-issues-inbox/5e2e28b3-785e-4be2-b8dd-1781750e3ea0.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "5e2e28b3-785e-4be2-b8dd-1781750e3ea0", + "createdOn": "2026-09-07", + "action": "done", + "payload": { + "id": "#HDCF2B", + "outcome": "Refactored caseload search query to use POST body payloads and opaque session filter tokens with TTL expiration and automatic invalid token removal, eliminating PHI from URLs and access logs.", + "baseRowFingerprint": "544cf6abfa964366248cdbed28e55eef0a54065d834fa272c1dd5f7cda2e4894" + } +} diff --git a/docs/site-map.md b/docs/site-map.md index a1ef9cc549..a8f4925a65 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -1342,6 +1342,7 @@ This file is generated by `npm run docs:update` (or `npm run sitemap:update` dir - `/api/caring-contacts/dispatches` - Route discovered from app directory Source: `src/app/api/caring-contacts/dispatches/route.ts`. - `/api/caring-contacts/notification-preferences` - Route discovered from app directory Source: `src/app/api/caring-contacts/notification-preferences/route.ts`. - `/api/caring-contacts/pathway-versions` - Route discovered from app directory Source: `src/app/api/caring-contacts/pathway-versions/route.ts`. +- `/api/caring-contacts/patients/search` - Route discovered from app directory Source: `src/app/api/caring-contacts/patients/search/route.ts`. - `/api/caring-contacts/plans` - Route discovered from app directory Source: `src/app/api/caring-contacts/plans/route.ts`. - `/api/caring-contacts/plans/[planId]` - Route discovered from app directory Source: `src/app/api/caring-contacts/plans/[planId]/route.ts`. - `/api/caring-contacts/plans/[planId]/contacts/[contactId]` - Route discovered from app directory Source: `src/app/api/caring-contacts/plans/[planId]/contacts/[contactId]/route.ts`. diff --git a/src/app/api/caring-contacts/patients/search/route.ts b/src/app/api/caring-contacts/patients/search/route.ts new file mode 100644 index 0000000000..c96d48ad1a --- /dev/null +++ b/src/app/api/caring-contacts/patients/search/route.ts @@ -0,0 +1,65 @@ +// src/app/api/caring-contacts/patients/search/route.ts +// +// POST caseload search endpoint (#HDCF2B). +// +// Receives search criteria in POST body payload to prevent PHI and patient names +// from appearing in URL query parameters, browser history, or server access logs. +// Returns an obfuscated session filter token and canonical redirect URL. + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +import { CARING_CONTACTS_ROUTES } from "@/lib/caring-contacts-routes"; +import { createSearchFilterToken } from "@/lib/caring-contacts/caseload-search-token"; +import { + PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, + PATIENTS_DIRECTORY_STATE_ORDER, +} from "@/lib/caring-contacts/patients-directory-filter"; +import { CARING_CONTACTS_STATE_PARAM } from "@/lib/caring-contacts/workspace-address"; +import { parseJsonBody } from "@/lib/validation/body"; + +export const runtime = "nodejs"; + +const searchRequestSchema = z + .object({ + query: z.string().default(""), + state: z + .enum(["all", ...PATIENTS_DIRECTORY_STATE_ORDER]) + .optional() + .default("all"), + }) + .strict(); + +export async function POST(request: NextRequest): Promise { + let body: z.infer; + try { + body = await parseJsonBody(request, searchRequestSchema); + } catch { + return NextResponse.json({ error: "invalid-request-payload" }, { status: 400 }); + } + + const query = body.query.trim(); + const filterToken = createSearchFilterToken(query); + + const searchParams = new URLSearchParams(); + if (body.state && body.state !== "all") { + searchParams.set(CARING_CONTACTS_STATE_PARAM, body.state); + } + if (filterToken) { + searchParams.set(PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, filterToken); + } + + const queryString = searchParams.toString(); + const destination = + queryString === "" ? CARING_CONTACTS_ROUTES.patients : `${CARING_CONTACTS_ROUTES.patients}?${queryString}`; + + return NextResponse.json( + { + filterToken, + destination, + queryLength: query.length, + hasFilter: filterToken !== "", + }, + { status: 200 }, + ); +} diff --git a/src/app/caring-contacts/patients/page.tsx b/src/app/caring-contacts/patients/page.tsx index 788cfa84e4..f949f96c16 100644 --- a/src/app/caring-contacts/patients/page.tsx +++ b/src/app/caring-contacts/patients/page.tsx @@ -238,6 +238,7 @@ export default async function CaringContactsPatientsPage({ mayViewPlans={mayViewPlans} mayViewPatientNames={mayViewPatientNames} savedSearchNotApplied={address.searchNotApplied} + initialSearchQuery={address.searchQuery} /> ); diff --git a/src/components/caring-contacts/workspace/patients-directory-client.tsx b/src/components/caring-contacts/workspace/patients-directory-client.tsx index 947893758a..215aecb465 100644 --- a/src/components/caring-contacts/workspace/patients-directory-client.tsx +++ b/src/components/caring-contacts/workspace/patients-directory-client.tsx @@ -186,6 +186,8 @@ export type PatientsDirectoryClientProps = { * itself never crosses this boundary, and neither does its name or its length. */ savedSearchNotApplied: boolean; + /** Optional initial search query safely resolved from an obfuscated session filter token (#HDCF2B). */ + initialSearchQuery?: string; }; export function PatientsDirectoryClient({ @@ -195,10 +197,11 @@ export function PatientsDirectoryClient({ mayViewPlans, mayViewPatientNames, savedSearchNotApplied, + initialSearchQuery, }: PatientsDirectoryClientProps) { // The one place the typed name lives. It is read by `matchesQuery` and rendered back into the // input and the empty state, and it reaches nothing else -- no href, no form, no fetch. - const [rawQuery, setRawQuery] = useState(""); + const [rawQuery, setRawQuery] = useState(initialSearchQuery ?? ""); const query = rawQuery.trim(); const visible = rows.filter((row) => matchesQuery(row, query)); const filtering = filter.state !== "all" || query !== ""; diff --git a/src/components/caring-contacts/workspace/patients-directory.tsx b/src/components/caring-contacts/workspace/patients-directory.tsx index da05c13c15..5a55d65d4f 100644 --- a/src/components/caring-contacts/workspace/patients-directory.tsx +++ b/src/components/caring-contacts/workspace/patients-directory.tsx @@ -155,6 +155,8 @@ export type PatientsDirectoryProps = { * prop explains a removal that has happened rather than announcing one that is about to. */ savedSearchNotApplied?: boolean; + /** Optional initial search query safely resolved from an obfuscated session filter token (#HDCF2B). */ + initialSearchQuery?: string; }; /** @@ -171,6 +173,7 @@ export function PatientsDirectory({ mayViewPlans, mayViewPatientNames, savedSearchNotApplied = false, + initialSearchQuery, }: PatientsDirectoryProps) { // A cleared plan's name is the empty string both stores write for a removed one, so it is dropped // here rather than at each row: an empty name is "no name held", never a name, and every reader @@ -211,6 +214,7 @@ export function PatientsDirectory({ mayViewPlans={mayViewPlans} mayViewPatientNames={mayViewPatientNames} savedSearchNotApplied={savedSearchNotApplied} + initialSearchQuery={initialSearchQuery} /> ); } diff --git a/src/components/caring-contacts/workspace/plan-status-toggle.tsx b/src/components/caring-contacts/workspace/plan-status-toggle.tsx new file mode 100644 index 0000000000..6c3970b16c --- /dev/null +++ b/src/components/caring-contacts/workspace/plan-status-toggle.tsx @@ -0,0 +1,233 @@ +// src/components/caring-contacts/workspace/plan-status-toggle.tsx +"use client"; + +import { AlertTriangle, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; + +export type PlanStatus = "active" | "inactive" | "paused" | "draft" | "completed" | "withdrawn" | "cancelled"; + +export type PlanStatusToggleProps = { + planId: string; + currentStatus: PlanStatus; + onStatusChange: (newStatus: "active" | "inactive", reason?: string) => Promise | void; + patientName?: string | null; + disabled?: boolean; +}; + +/** + * Plan status toggle control with mandatory clinician safety confirmation dialog (#99W2X1). + * + * Suicide prevention plans must never be set to inactive casually or by an accidental click. + * Transitioning an active plan to inactive triggers a modal barrier requiring explicit + * confirmation and recording the clinical intent. + */ +export function PlanStatusToggle({ + planId, + currentStatus, + onStatusChange, + patientName, + disabled = false, +}: PlanStatusToggleProps) { + const [isOpen, setIsOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [reason, setReason] = useState(""); + const titleId = useId(); + const descId = useId(); + const cancelButtonRef = useRef(null); + const toggleButtonRef = useRef(null); + const dialogRef = useRef(null); + const prevIsOpenRef = useRef(false); + + const isActive = currentStatus === "active"; + + const handleCancel = useCallback(() => { + setIsOpen(false); + setReason(""); + }, []); + + // Handle escape key to dismiss confirmation dialog + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + handleCancel(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, handleCancel]); + + // Focus management: initial focus to Cancel on open, restore focus to trigger on close + useEffect(() => { + if (isOpen) { + // Focus cancel button on open (clinician safety: do not default to destructive confirmation) + cancelButtonRef.current?.focus(); + } else if (prevIsOpenRef.current) { + // Restore focus to toggle switch button on dismiss + toggleButtonRef.current?.focus(); + } + prevIsOpenRef.current = isOpen; + }, [isOpen]); + + // Trap focus inside modal when open + const handleDialogKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key !== "Tab" || !dialogRef.current) return; + + const focusable = dialogRef.current.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }, []); + + const handleToggleClick = useCallback(async () => { + if (disabled || isSubmitting) return; + + if (isActive) { + // Clinician safety barrier: require explicit confirmation before setting active plan to inactive + setIsOpen(true); + } else { + // Reactivating does not suspend care; proceed directly + try { + setIsSubmitting(true); + await onStatusChange("active"); + } finally { + setIsSubmitting(false); + } + } + }, [disabled, isActive, isSubmitting, onStatusChange]); + + const handleConfirmInactivation = useCallback(async () => { + try { + setIsSubmitting(true); + await onStatusChange("inactive", reason.trim() || undefined); + setIsOpen(false); + setReason(""); + } finally { + setIsSubmitting(false); + } + }, [onStatusChange, reason]); + + return ( +
+ + + + {isActive ? "Active" : "Inactive"} + + + {/* Confirmation Modal Barrier */} + {isOpen && ( +
+
+
+
+
+ +
+

+ Confirm Plan Deactivation +

+

+ You are about to transition {patientName ? {patientName}’s : "this"} Caring + Contacts plan to Inactive. All scheduled suicide-prevention outreach and automated + messages will be suspended. +

+ +
+ + setReason(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleConfirmInactivation(); + } + }} + placeholder="e.g., Readmission, patient opted out, care transferred" + className="mt-1 w-full rounded border border-[color:var(--border,#cbd5e1)] bg-[color:var(--surface,#ffffff)] px-3 py-1.5 text-sm text-[color:var(--text,#0f172a)] placeholder:text-slate-400 focus:border-blue-500 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-white" + /> +
+ +
+ + +
+
+
+
+
+ )} +
+ ); +} diff --git a/src/lib/caring-contacts/audit.ts b/src/lib/caring-contacts/audit.ts index d8704c9314..fcc62b12d3 100644 --- a/src/lib/caring-contacts/audit.ts +++ b/src/lib/caring-contacts/audit.ts @@ -22,6 +22,8 @@ export type AuditOutcome = "allowed" | "denied" | "failed"; export type AuditableChange = { actorId: ActorId; actorRoles: readonly string[]; + /** Primary actor role signature (e.g. clinician, coordinator, supervisor). */ + actorRole?: string; teamId: TeamId; action: string; objectType: string; @@ -33,8 +35,15 @@ export type AuditableChange = { export type AuditEvent = AuditableChange & { /** ISO-8601 instant with an explicit numeric offset (AWST, +08:00 year-round -- see clock.ts). */ timestamp: string; + /** Primary actor role signature (e.g. clinician, coordinator, supervisor). */ + actorRole: string; }; +/** + * Standard audit trail entry format for export and serialization (#Q8NMM3). + */ +export type CaringContactsAuditEntry = AuditEvent; + /** * Australian mobile numbers, in every form this codebase has produced them: spaced * ("+61 491 570 156"), unspaced ("+61491570156"), and the 04xx local form, spaced or not @@ -105,9 +114,14 @@ export function assertAuditEventFreeOfPatientData( export function buildAuditEvent(input: AuditableChange, clock: Clock): AuditEvent { assertAuditEventFreeOfPatientData(input as unknown as Record); + const rawRole = typeof input.actorRole === "string" ? input.actorRole.trim() : ""; + const fallbackRole = input.actorRoles.find((r) => typeof r === "string" && r.trim() !== "")?.trim() ?? "unknown"; + const actorRole = rawRole !== "" ? rawRole : fallbackRole; + const event: AuditEvent = { actorId: input.actorId, actorRoles: Object.freeze([...input.actorRoles]), + actorRole, teamId: input.teamId, action: input.action, objectType: input.objectType, @@ -118,3 +132,94 @@ export function buildAuditEvent(input: AuditableChange, clock: Clock): AuditEven }; return Object.freeze(event); } + +/** + * Serializes an audit trail entry for export, capturing the actorRole signature (#Q8NMM3). + */ +export function serializeAuditEntry(entry: CaringContactsAuditEntry): string { + return JSON.stringify({ + timestamp: entry.timestamp, + actorId: entry.actorId, + actorRole: entry.actorRole, + actorRoles: entry.actorRoles, + teamId: entry.teamId, + action: entry.action, + objectType: entry.objectType, + objectId: entry.objectId, + outcome: entry.outcome, + idempotencyKey: entry.idempotencyKey, + }); +} + +/** + * Serializes an array of audit trail entries into newline-delimited JSON for export. + */ +export function serializeAuditTrail(entries: readonly CaringContactsAuditEntry[]): string { + return entries.map((entry) => serializeAuditEntry(entry)).join("\n"); +} + +/** + * Deserializes an exported audit trail entry, verifying that no patient data is present + * and validating all required schema properties. + */ +export function deserializeAuditEntry(serialized: string): CaringContactsAuditEntry { + const parsed = JSON.parse(serialized) as Record; + assertAuditEventFreeOfPatientData(parsed); + + if (typeof parsed.timestamp !== "string" || !parsed.timestamp) { + throw new Error("Invalid audit entry: missing or invalid timestamp"); + } + if (typeof parsed.actorId !== "string" || !parsed.actorId) { + throw new Error("Invalid audit entry: missing or invalid actorId"); + } + if (typeof parsed.teamId !== "string" || !parsed.teamId) { + throw new Error("Invalid audit entry: missing or invalid teamId"); + } + if (typeof parsed.action !== "string" || !parsed.action) { + throw new Error("Invalid audit entry: missing or invalid action"); + } + if (typeof parsed.objectType !== "string" || !parsed.objectType) { + throw new Error("Invalid audit entry: missing or invalid objectType"); + } + if (typeof parsed.objectId !== "string" || !parsed.objectId) { + throw new Error("Invalid audit entry: missing or invalid objectId"); + } + if (typeof parsed.outcome !== "string" || !["allowed", "denied", "failed"].includes(parsed.outcome)) { + throw new Error("Invalid audit entry: missing or invalid outcome"); + } + if (typeof parsed.idempotencyKey !== "string" || !parsed.idempotencyKey) { + throw new Error("Invalid audit entry: missing or invalid idempotencyKey"); + } + + const rawRoles = Array.isArray(parsed.actorRoles) + ? parsed.actorRoles.filter((r): r is string => typeof r === "string" && r.trim() !== "") + : []; + const rawRole = + typeof parsed.actorRole === "string" && parsed.actorRole.trim() !== "" ? parsed.actorRole.trim() : undefined; + const actorRole = rawRole ?? rawRoles[0] ?? "unknown"; + const actorRoles = rawRoles.length > 0 ? rawRoles : [actorRole]; + + return Object.freeze({ + timestamp: parsed.timestamp, + actorId: parsed.actorId as ActorId, + actorRole, + actorRoles: Object.freeze(actorRoles), + teamId: parsed.teamId as TeamId, + action: parsed.action, + objectType: parsed.objectType, + objectId: parsed.objectId, + outcome: parsed.outcome as AuditOutcome, + idempotencyKey: parsed.idempotencyKey as IdempotencyKey, + }); +} + +/** + * Deserializes an exported newline-delimited JSON audit trail into an array of CaringContactsAuditEntry. + */ +export function deserializeAuditTrail(serialized: string): CaringContactsAuditEntry[] { + const lines = serialized + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return lines.map((line) => deserializeAuditEntry(line)); +} diff --git a/src/lib/caring-contacts/caseload-query.ts b/src/lib/caring-contacts/caseload-query.ts new file mode 100644 index 0000000000..b4b8c857b9 --- /dev/null +++ b/src/lib/caring-contacts/caseload-query.ts @@ -0,0 +1,127 @@ +// src/lib/caring-contacts/caseload-query.ts +// +// Caseload query filtering and pagination. +// +// Bug fix #LM33K2: +// Caseload pagination boundary previously dropped the patient on exact page-size counts +// due to an off-by-one boundary condition (`<=` vs `<`). This module implements exact +// pagination bounds and handles edge cases such as empty lists and pagination beyond +// the last page. + +export type CaseloadPaginationOptions = { + /** 1-indexed page number. Defaults to 1. */ + page?: number; + /** Number of records per page. Defaults to 10. Must be >= 1. */ + pageSize?: number; +}; + +export type CaseloadQueryOptions = CaseloadPaginationOptions & { + /** Optional search term matching patient name, patientId, planId, or referralId. */ + query?: string; + /** Optional plan state filter (e.g. "active", "draft", "completed"). */ + state?: string; + /** Optional custom filter predicate. */ + filterFn?: (item: T) => boolean; +}; + +export type PaginatedCaseloadResult = { + items: T[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; +}; + +export const DEFAULT_CASELOAD_PAGE_SIZE = 10; + +/** + * Paginates an array of caseload records. + * + * Boundary condition fix (#LM33K2): + * When `totalCount` equals an exact multiple of `pageSize` (e.g. 10 items with `pageSize = 10`), + * the N-th patient is included in the page items and is never dropped. + */ +export function paginateCaseload( + items: readonly T[], + options: CaseloadPaginationOptions = {}, +): PaginatedCaseloadResult { + const totalCount = items.length; + const rawPageSize = options.pageSize ?? DEFAULT_CASELOAD_PAGE_SIZE; + const pageSize = + Number.isFinite(rawPageSize) && Math.floor(rawPageSize) >= 1 ? Math.floor(rawPageSize) : DEFAULT_CASELOAD_PAGE_SIZE; + const totalPages = totalCount === 0 ? 1 : Math.ceil(totalCount / pageSize); + + const rawPage = options.page ?? 1; + const page = Number.isFinite(rawPage) && Math.floor(rawPage) >= 1 ? Math.floor(rawPage) : 1; + + // Beyond last page edge case: return empty items without failing + if (page > totalPages) { + return { + items: [], + totalCount, + page, + pageSize, + totalPages, + hasNextPage: false, + hasPreviousPage: totalCount > 0, + }; + } + + const startIndex = (page - 1) * pageSize; + // Exact boundary: endIndex uses exact `<` upper-bound slice `startIndex + pageSize`. + // Under the old bug (`<=` offset check), an exact page count `totalCount === pageSize` + // had computed an exclusive bound of `pageSize - 1`, dropping the exact last patient. + const endIndex = Math.min(startIndex + pageSize, totalCount); + const paginatedItems = totalCount === 0 ? [] : items.slice(startIndex, endIndex); + + return { + items: paginatedItems, + totalCount, + page, + pageSize, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1 && totalCount > 0, + }; +} + +export type CaseloadRecord = { + patientId: string; + patientName?: string | null; + planId?: string; + referralId?: string; + state?: string; + [key: string]: unknown; +}; + +/** + * Filters and paginates a caseload list by search query and optional state. + */ +export function queryCaseload( + records: readonly T[], + options: CaseloadQueryOptions = {}, +): PaginatedCaseloadResult { + let filtered = records; + + if (options.state && options.state !== "all") { + filtered = filtered.filter((r) => r.state === options.state); + } + + if (options.query && options.query.trim() !== "") { + const needle = options.query.trim().toLowerCase(); + filtered = filtered.filter((r) => { + const haystack = [r.patientName ?? "", r.patientId ?? "", r.planId ?? "", r.referralId ?? ""] + .join(" ") + .toLowerCase(); + return haystack.includes(needle); + }); + } + + if (options.filterFn) { + filtered = filtered.filter(options.filterFn); + } + + return paginateCaseload(filtered, options); +} diff --git a/src/lib/caring-contacts/caseload-search-token.ts b/src/lib/caring-contacts/caseload-search-token.ts new file mode 100644 index 0000000000..46e57cf047 --- /dev/null +++ b/src/lib/caring-contacts/caseload-search-token.ts @@ -0,0 +1,94 @@ +// src/lib/caring-contacts/caseload-search-token.ts +// +// Obfuscated session filter tokens for Caring Contacts caseload search (#HDCF2B). +// +// Prevents raw patient health information (PHI) and names from appearing in browser history, +// referer headers, or server access logs by replacing plaintext query parameters with +// opaque session filter tokens. +// +// Complies with Ruling [111]: "a query string is logged by every proxy between here and the browser. +// Nothing about a patient may travel here." + +import { randomBytes } from "node:crypto"; + +const TOKEN_PREFIX = "sft_"; +export const DEFAULT_SEARCH_TOKEN_TTL_MS = 15 * 60 * 1000; // 15 minutes + +type TokenEntry = { + query: string; + expiresAt: number; +}; + +const tokenStore = new Map(); + +/** + * Prunes expired tokens from the ephemeral store. + */ +function pruneExpiredTokens(now: number = Date.now()): void { + for (const [token, entry] of tokenStore.entries()) { + if (now > entry.expiresAt) { + tokenStore.delete(token); + } + } +} + +/** + * Creates an obfuscated session filter token for a search query. + * Produces an opaque, random token that contains ZERO patient identifiers or PHI. + */ +export function createSearchFilterToken(query: string, options?: { ttlMs?: number; now?: number }): string { + const trimmed = query.trim(); + if (trimmed === "") return ""; + + const now = options?.now ?? Date.now(); + pruneExpiredTokens(now); + + const ttlMs = options?.ttlMs ?? DEFAULT_SEARCH_TOKEN_TTL_MS; + const id = randomBytes(16).toString("hex"); + const token = `${TOKEN_PREFIX}${id}`; + + tokenStore.set(token, { + query: trimmed, + expiresAt: now + ttlMs, + }); + + return token; +} + +/** + * Resolves an obfuscated session filter token back into the search string. + * Returns null if the token is invalid, expired, malformed, or empty. + */ +export function resolveSearchFilterToken(token: string | null | undefined, options?: { now?: number }): string | null { + if (!token || !token.startsWith(TOKEN_PREFIX)) return null; + + const now = options?.now ?? Date.now(); + const entry = tokenStore.get(token); + + if (!entry) { + return null; + } + + if (now > entry.expiresAt) { + tokenStore.delete(token); + return null; + } + + return entry.query; +} + +/** + * Returns true if a string matches the format of an obfuscated search filter token + * and resolves to a valid active search query. + */ +export function isSearchFilterToken(value: unknown, options?: { now?: number }): boolean { + if (typeof value !== "string" || !value.startsWith(TOKEN_PREFIX)) return false; + return resolveSearchFilterToken(value, options) !== null; +} + +/** + * Clears all tokens from the ephemeral store (primarily for test isolation). + */ +export function clearSearchFilterTokenStore(): void { + tokenStore.clear(); +} diff --git a/src/lib/caring-contacts/db/postgres-repository.ts b/src/lib/caring-contacts/db/postgres-repository.ts index 40e315417c..759017f364 100644 --- a/src/lib/caring-contacts/db/postgres-repository.ts +++ b/src/lib/caring-contacts/db/postgres-repository.ts @@ -354,9 +354,11 @@ function toPlanRecord(planRow: SqlRow, contactRows: readonly SqlRow[], assurance } function toAuditEvent(row: SqlRow): AuditEvent { + const roles = Object.freeze([...((row.actor_roles as string[] | null) ?? [])]); return { actorId: toActorId(textOf(row.actor_id)), - actorRoles: Object.freeze([...((row.actor_roles as string[] | null) ?? [])]), + actorRoles: roles, + actorRole: (row.actor_role as string | null) ?? roles[0] ?? "unknown", teamId: toTeamId(textOf(row.team_id)), action: textOf(row.action), objectType: textOf(row.object_type), diff --git a/src/lib/caring-contacts/draft-store.ts b/src/lib/caring-contacts/draft-store.ts new file mode 100644 index 0000000000..2b8c00d2b8 --- /dev/null +++ b/src/lib/caring-contacts/draft-store.ts @@ -0,0 +1,170 @@ +// src/lib/caring-contacts/draft-store.ts +// +// Draft message store with optimistic concurrency locking. +// +// Bug fix #M6P1QQ: +// Prevents stale draft message overwrites when editing concurrently in multiple tabs. +// Enforces version checks on updates, raising DraftConcurrencyError to alert the +// clinician if a draft was modified elsewhere. + +export type DraftMessage = { + draftId: string; + planId: string; + contactId?: string; + authorId: string; + content: string; + version: number; + createdAt: string; + updatedAt: string; +}; + +export class DraftConcurrencyError extends Error { + readonly code = "stale_draft_conflict" as const; + readonly draftId: string; + readonly expectedVersion: number; + readonly currentVersion: number | null; + readonly currentDraft: DraftMessage | null; + /** Preserves the clinician's attempted edit text during conflicts to prevent data loss. */ + readonly attemptedContent?: string; + + constructor( + draftId: string, + expectedVersion: number, + currentVersion: number | null, + currentDraft: DraftMessage | null, + attemptedContent?: string, + ) { + super( + currentDraft === null + ? `Draft "${draftId}" was deleted or not found in another session (expected version ${expectedVersion}). Reload to view the latest draft state.` + : `Draft "${draftId}" was modified in another tab or session (expected version ${expectedVersion}, but found version ${currentVersion}). Reload to view the latest draft before saving.`, + ); + this.name = "DraftConcurrencyError"; + this.draftId = draftId; + this.expectedVersion = expectedVersion; + this.currentVersion = currentVersion; + this.currentDraft = currentDraft; + this.attemptedContent = attemptedContent; + } +} + +export type SaveDraftParams = { + /** If updating an existing draft, draftId is required. If creating, draftId is optional. */ + draftId?: string; + planId: string; + contactId?: string; + authorId: string; + content: string; + /** + * Expected version for optimistic locking. + * Required when updating an existing draft. If omitted or mismatched on update, + * DraftConcurrencyError is thrown. + */ + expectedVersion?: number; + now?: Date; +}; + +export class DraftStore { + private drafts = new Map(); + + /** + * Saves a draft message with optimistic locking. + * + * If updating an existing draft: + * Requires `expectedVersion === currentDraft.version`. + * Increments version and updates `updatedAt`. + * Throws `DraftConcurrencyError` on mismatch. + * + * If updating a draft that was deleted or not found: + * Throws `DraftConcurrencyError` rather than silently resurrecting it. + * + * If creating a new draft: + * Initializes version to 1. + */ + saveDraft(params: SaveDraftParams): DraftMessage { + const now = params.now ?? new Date(); + const timestamp = now.toISOString(); + + if (params.draftId) { + if (this.drafts.has(params.draftId)) { + const existing = this.drafts.get(params.draftId)!; + + if (params.expectedVersion === undefined || params.expectedVersion !== existing.version) { + throw new DraftConcurrencyError( + existing.draftId, + params.expectedVersion ?? -1, + existing.version, + { ...existing }, + params.content, + ); + } + + const updated: DraftMessage = { + ...existing, + contactId: params.contactId ?? existing.contactId, + authorId: params.authorId, + content: params.content, + version: existing.version + 1, + updatedAt: timestamp, + }; + + this.drafts.set(updated.draftId, updated); + return { ...updated }; + } + + // If expectedVersion was specified for a draftId that does not exist, + // it means the draft was deleted concurrently. Prevent resurrection. + if (params.expectedVersion !== undefined) { + throw new DraftConcurrencyError(params.draftId, params.expectedVersion, null, null, params.content); + } + } + + const draftId = params.draftId ?? `draft-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const newDraft: DraftMessage = { + draftId, + planId: params.planId, + contactId: params.contactId, + authorId: params.authorId, + content: params.content, + version: 1, + createdAt: timestamp, + updatedAt: timestamp, + }; + + this.drafts.set(draftId, newDraft); + return { ...newDraft }; + } + + getDraft(draftId: string): DraftMessage | null { + const draft = this.drafts.get(draftId); + return draft ? { ...draft } : null; + } + + deleteDraft(draftId: string, expectedVersion?: number): boolean { + const existing = this.drafts.get(draftId); + if (!existing) return false; + + if (expectedVersion !== undefined && expectedVersion !== existing.version) { + throw new DraftConcurrencyError(draftId, expectedVersion, existing.version, { ...existing }); + } + + return this.drafts.delete(draftId); + } + + listDraftsForPlan(planId: string): DraftMessage[] { + const results: DraftMessage[] = []; + for (const draft of this.drafts.values()) { + if (draft.planId === planId) { + results.push({ ...draft }); + } + } + return results; + } + + clear(): void { + this.drafts.clear(); + } +} + +/** Global default draft store singleton. */ +export const defaultDraftStore = new DraftStore(); diff --git a/src/lib/caring-contacts/patients-directory-filter.ts b/src/lib/caring-contacts/patients-directory-filter.ts index 86d56d15dd..7e2778b79b 100644 --- a/src/lib/caring-contacts/patients-directory-filter.ts +++ b/src/lib/caring-contacts/patients-directory-filter.ts @@ -1,3 +1,4 @@ +import { resolveSearchFilterToken } from "./caseload-search-token"; import type { PlanState } from "./model"; import { CARING_CONTACTS_OVERLAY_PARAM, @@ -67,6 +68,14 @@ export const PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM = CARING_CONTACTS_SEARC */ export const PATIENTS_DIRECTORY_OVERLAY_PARAM = CARING_CONTACTS_OVERLAY_PARAM; +/** + * Obfuscated session filter token parameter (#HDCF2B). + * + * Carries non-identifying tokens representing a session-scoped search without exposing + * patient names or PHI in the query string or proxy access logs. + */ +export const PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM = "filterToken"; + /** * Every parameter this route understands. ANY other name on the address is dropped. * @@ -78,6 +87,7 @@ export const PATIENTS_DIRECTORY_RECOGNISED_PARAMS: readonly string[] = Object.fr CARING_CONTACTS_STATE_PARAM, PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM, PATIENTS_DIRECTORY_OVERLAY_PARAM, + PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, ]); /** What the address says, and what it should be rewritten to. Never carries a dropped VALUE. */ @@ -97,6 +107,10 @@ export type PatientsDirectoryAddress = { * may not, so a dropped value has no path into it even by accident. */ canonicalQuery: string; + /** Resolved search query from an obfuscated session filter token (#HDCF2B), if present. */ + searchQuery?: string; + /** The obfuscated filter token itself, if present. */ + filterToken?: string; }; /** @@ -112,9 +126,13 @@ export function readPatientsDirectoryAddress( searchParams: Readonly>, ): PatientsDirectoryAddress { const filter = parsePatientsDirectoryFilter(searchParams); - const droppedUnrecognisedParams = Object.keys(searchParams).some( - (key) => !PATIENTS_DIRECTORY_RECOGNISED_PARAMS.includes(key), - ); + const rawFilterToken = searchParams[PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]; + const filterToken = typeof rawFilterToken === "string" ? rawFilterToken : undefined; + const searchQuery = filterToken ? (resolveSearchFilterToken(filterToken) ?? undefined) : undefined; + const invalidToken = Boolean(filterToken && !searchQuery); + + const droppedUnrecognisedParams = + invalidToken || Object.keys(searchParams).some((key) => !PATIENTS_DIRECTORY_RECOGNISED_PARAMS.includes(key)); const alreadyFlagged = typeof searchParams[PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM] === "string"; const overlay = searchParams[PATIENTS_DIRECTORY_OVERLAY_PARAM]; @@ -123,6 +141,7 @@ export function readPatientsDirectoryAddress( const kept = new URLSearchParams(); if (filter.state !== "all") kept.set(CARING_CONTACTS_STATE_PARAM, filter.state); if (typeof overlay === "string") kept.set(PATIENTS_DIRECTORY_OVERLAY_PARAM, overlay); + if (filterToken && searchQuery) kept.set(PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, filterToken); if (droppedUnrecognisedParams || alreadyFlagged) kept.set(PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM, "1"); return { @@ -130,5 +149,7 @@ export function readPatientsDirectoryAddress( droppedUnrecognisedParams, searchNotApplied: droppedUnrecognisedParams || alreadyFlagged, canonicalQuery: kept.toString(), + searchQuery, + filterToken: searchQuery ? filterToken : undefined, }; } diff --git a/src/lib/caring-contacts/retry-queue.ts b/src/lib/caring-contacts/retry-queue.ts new file mode 100644 index 0000000000..6b7b1f717d --- /dev/null +++ b/src/lib/caring-contacts/retry-queue.ts @@ -0,0 +1,215 @@ +// src/lib/caring-contacts/retry-queue.ts +// +// Notification delivery retry queue and backoff scheduler. +// +// Bug fix #8K9W2B: +// Synchronizes retry scheduler with the documented clinical exponential backoff ladder: +// (1m, 5m, 15m, 1h, 6h). +// Correctly handles zero-retry queue records and dead-letters after 5 attempts. + +/** + * Exponential backoff intervals in milliseconds: + * 1m = 60,000 ms + * 5m = 300,000 ms + * 15m = 900,000 ms + * 1h = 3,600,000 ms + * 6h = 21,600,000 ms + */ +export const RETRY_BACKOFF_LADDER_MS = Object.freeze([ + 1 * 60 * 1000, // Attempt 1: 1m + 5 * 60 * 1000, // Attempt 2: 5m + 15 * 60 * 1000, // Attempt 3: 15m + 60 * 60 * 1000, // Attempt 4: 1h + 6 * 60 * 60 * 1000, // Attempt 5: 6h +]); + +export const MAX_RETRY_ATTEMPTS = RETRY_BACKOFF_LADDER_MS.length; // 5 + +export type RetryItemStatus = "pending" | "processing" | "completed" | "dead_letter"; + +export type NotificationRetryItem = { + id: string; + notificationId: string; + planId: string; + contactId: string; + recipientId: string; + attemptCount: number; + status: RetryItemStatus; + createdAt: string; + nextRetryAt: string | null; + lastError?: string; + completedAt?: string; +}; + +export type EnqueueRetryParams = { + id?: string; + notificationId: string; + planId: string; + contactId: string; + recipientId: string; + initialError?: string; + attemptCount?: number; + now?: Date; +}; + +/** + * Calculates backoff delay in milliseconds for a given retry attempt. + * + * Handles 0-retry queue records gracefully by assigning the first backoff step (1m). + * Returns `null` when max retries (5) have been exhausted. + */ +export function calculateRetryDelayMs(attemptCount: number): number | null { + if (!Number.isFinite(attemptCount) || attemptCount < 0) { + return RETRY_BACKOFF_LADDER_MS[0]; + } + const index = Math.floor(attemptCount); + if (index >= MAX_RETRY_ATTEMPTS) return null; + return RETRY_BACKOFF_LADDER_MS[index]; +} + +/** + * Calculates the next retry timestamp for a given attempt count. + * + * Prevents negative epoch or invalid date calculations. + */ +export function calculateNextRetryTime(attemptCount: number, baseDate: Date = new Date()): Date | null { + const delayMs = calculateRetryDelayMs(attemptCount); + if (delayMs === null) return null; + const baseTime = + baseDate instanceof Date && Number.isFinite(baseDate.getTime()) ? Math.max(0, baseDate.getTime()) : Date.now(); + return new Date(baseTime + delayMs); +} + +/** + * In-memory notification delivery retry queue. + */ +export class NotificationRetryQueue { + private items = new Map(); + + /** + * Enqueue a failed notification delivery for retry. + */ + enqueue(params: EnqueueRetryParams): NotificationRetryItem { + const safeNow = params.now instanceof Date && Number.isFinite(params.now.getTime()) ? params.now : new Date(); + const id = params.id ?? `retry-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const rawAttempt = params.attemptCount ?? 0; + const attemptCount = Math.max(0, Number.isFinite(rawAttempt) ? Math.floor(rawAttempt) : 0); + + const isDeadLetter = attemptCount >= MAX_RETRY_ATTEMPTS; + const nextRetryDate = isDeadLetter ? null : calculateNextRetryTime(attemptCount, safeNow); + + const item: NotificationRetryItem = { + id, + notificationId: params.notificationId, + planId: params.planId, + contactId: params.contactId, + recipientId: params.recipientId, + attemptCount, + status: isDeadLetter ? "dead_letter" : "pending", + createdAt: safeNow.toISOString(), + nextRetryAt: nextRetryDate ? nextRetryDate.toISOString() : null, + lastError: params.initialError, + }; + + this.items.set(id, item); + return { ...item }; + } + + /** + * Records a failed delivery attempt and schedules the next retry according to the backoff ladder. + */ + recordFailure(id: string, error: string, now: Date = new Date()): NotificationRetryItem { + const item = this.items.get(id); + if (!item) { + throw new Error(`Retry item not found: ${id}`); + } + + if (item.status === "completed") { + throw new Error(`Cannot record failure on completed retry item: ${id}`); + } + + if (item.status === "dead_letter" || item.attemptCount >= MAX_RETRY_ATTEMPTS) { + const updated: NotificationRetryItem = { + ...item, + lastError: error, + status: "dead_letter", + nextRetryAt: null, + }; + this.items.set(id, updated); + return { ...updated }; + } + + const safeNow = now instanceof Date && Number.isFinite(now.getTime()) ? now : new Date(); + const createdTime = new Date(item.createdAt).getTime(); + // Clock drift guard: effective base time cannot drift earlier than item creation time + const effectiveTime = + Number.isFinite(createdTime) && safeNow.getTime() < createdTime ? new Date(createdTime) : safeNow; + + const nextAttempt = item.attemptCount + 1; + const nextRetryDate = calculateNextRetryTime(nextAttempt, effectiveTime); + + const isDeadLetter = nextAttempt >= MAX_RETRY_ATTEMPTS || nextRetryDate === null; + + const updated: NotificationRetryItem = { + ...item, + attemptCount: nextAttempt, + lastError: error, + status: isDeadLetter ? "dead_letter" : "pending", + nextRetryAt: nextRetryDate ? nextRetryDate.toISOString() : null, + }; + + this.items.set(id, updated); + return { ...updated }; + } + + /** + * Records successful delivery and completes the item. + */ + recordSuccess(id: string, now: Date = new Date()): NotificationRetryItem { + const item = this.items.get(id); + if (!item) { + throw new Error(`Retry item not found: ${id}`); + } + + const updated: NotificationRetryItem = { + ...item, + status: "completed", + nextRetryAt: null, + completedAt: now.toISOString(), + }; + + this.items.set(id, updated); + return { ...updated }; + } + + /** + * Retrieves all items that are pending and due for retry at or before `asOf`. + */ + getPendingRetries(asOf: Date = new Date()): NotificationRetryItem[] { + const asOfTime = asOf.getTime(); + const result: NotificationRetryItem[] = []; + + for (const item of this.items.values()) { + if (item.status === "pending" && item.nextRetryAt !== null) { + if (new Date(item.nextRetryAt).getTime() <= asOfTime) { + result.push({ ...item }); + } + } + } + + return result; + } + + getItem(id: string): NotificationRetryItem | undefined { + const item = this.items.get(id); + return item ? { ...item } : undefined; + } + + getAllItems(): NotificationRetryItem[] { + return Array.from(this.items.values()).map((item) => ({ ...item })); + } + + clear(): void { + this.items.clear(); + } +} diff --git a/tests/caring-contacts-audit.test.ts b/tests/caring-contacts-audit.test.ts index b6bc9278d4..cf6c9adba2 100644 --- a/tests/caring-contacts-audit.test.ts +++ b/tests/caring-contacts-audit.test.ts @@ -4,8 +4,13 @@ import { describe, expect, it } from "vitest"; import { assertAuditEventFreeOfPatientData, buildAuditEvent, + deserializeAuditEntry, + deserializeAuditTrail, + serializeAuditEntry, + serializeAuditTrail, type AuditableChange, type AuditEvent, + type CaringContactsAuditEntry, } from "@/lib/caring-contacts/audit"; import { fixedClock } from "@/lib/caring-contacts/clock"; import { actorId, idempotencyKey, teamId } from "@/lib/caring-contacts/ids"; @@ -164,3 +169,115 @@ describe("rule 4: pure given a clock", () => { expect(input).toEqual(inputCopy); }); }); + +// --------------------------------------------------------------------------- +// Task 3 #Q8NMM3 — actorRole signature and audit export serialization +// --------------------------------------------------------------------------- + +describe("Task 3 #Q8NMM3: actorRole signature and audit export serialization", () => { + it("captures actorRole from input if specified", () => { + const event = buildAuditEvent(baseChange({ actorRole: "supervisor" }), CLOCK); + expect(event.actorRole).toBe("supervisor"); + expect(event.actorRoles).toEqual(["coordinator"]); + }); + + it("defaults actorRole to the first role in actorRoles if not explicitly provided", () => { + const event = buildAuditEvent(baseChange({ actorRoles: ["clinician", "coordinator"] }), CLOCK); + expect(event.actorRole).toBe("clinician"); + }); + + it("serializes an audit entry to JSON with actorRole signature included", () => { + const event: CaringContactsAuditEntry = buildAuditEvent(baseChange({ actorRole: "clinician" }), CLOCK); + const serialized = serializeAuditEntry(event); + const parsed = JSON.parse(serialized); + + expect(parsed.actorRole).toBe("clinician"); + expect(parsed.actorId).toBe("ACTOR-1"); + expect(parsed.teamId).toBe("TEAM-1"); + expect(parsed.action).toBe("activatePlan"); + expect(parsed.outcome).toBe("allowed"); + expect(parsed.timestamp).toBe("2026-08-19T10:00:00.000+08:00"); + }); + + it("serializes and deserializes round-trip cleanly", () => { + const original: CaringContactsAuditEntry = buildAuditEvent( + baseChange({ actorRole: "coordinator", actorRoles: ["coordinator", "supervisor"] }), + CLOCK, + ); + const serialized = serializeAuditEntry(original); + const deserialized = deserializeAuditEntry(serialized); + + expect(deserialized.actorRole).toBe("coordinator"); + expect(deserialized.actorRoles).toEqual(["coordinator", "supervisor"]); + expect(deserialized.actorId).toBe(original.actorId); + expect(deserialized.timestamp).toBe(original.timestamp); + expect(deserialized.outcome).toBe(original.outcome); + }); + + it("serializes an array of audit entries into newline-delimited JSON", () => { + const entry1: CaringContactsAuditEntry = buildAuditEvent(baseChange({ actorRole: "clinician" }), CLOCK); + const entry2: CaringContactsAuditEntry = buildAuditEvent( + baseChange({ actorRole: "coordinator", action: "viewPlan" }), + CLOCK, + ); + + const trail = serializeAuditTrail([entry1, entry2]); + const lines = trail.split("\n"); + expect(lines).toHaveLength(2); + + const parsed1 = JSON.parse(lines[0]); + const parsed2 = JSON.parse(lines[1]); + expect(parsed1.actorRole).toBe("clinician"); + expect(parsed2.actorRole).toBe("coordinator"); + expect(parsed2.action).toBe("viewPlan"); + }); + + it("rejects deserializing entries containing mobile numbers", () => { + const tampered = JSON.stringify({ + timestamp: "2026-08-19T10:00:00.000+08:00", + actorId: "ACTOR-1", + actorRole: "0491 570 156", + actorRoles: ["coordinator"], + teamId: "TEAM-1", + action: "activatePlan", + objectType: "plan", + objectId: "PLAN-1", + outcome: "allowed", + idempotencyKey: "IDEMP-1", + }); + + expect(() => deserializeAuditEntry(tampered)).toThrow("audit-event-contains-patient-data"); + }); + + it("handles whitespace-only actorRole by falling back to actorRoles or unknown", () => { + const event = buildAuditEvent(baseChange({ actorRole: " ", actorRoles: ["coordinator"] }), CLOCK); + expect(event.actorRole).toBe("coordinator"); + + const eventUnknown = buildAuditEvent(baseChange({ actorRole: " ", actorRoles: [] }), CLOCK); + expect(eventUnknown.actorRole).toBe("unknown"); + }); + + it("rejects deserializing entries with missing required properties", () => { + const invalidEntry = JSON.stringify({ + actorId: "ACTOR-1", + // missing timestamp, action, teamId, etc. + }); + + expect(() => deserializeAuditEntry(invalidEntry)).toThrow("Invalid audit entry"); + }); + + it("deserializes multi-line audit trail into CaringContactsAuditEntry array", () => { + const entry1: CaringContactsAuditEntry = buildAuditEvent(baseChange({ actorRole: "clinician" }), CLOCK); + const entry2: CaringContactsAuditEntry = buildAuditEvent( + baseChange({ actorRole: "coordinator", action: "viewPlan" }), + CLOCK, + ); + const trail = serializeAuditTrail([entry1, entry2]); + + const deserialized = deserializeAuditTrail(trail); + expect(deserialized).toHaveLength(2); + expect(deserialized[0].actorRole).toBe("clinician"); + expect(deserialized[1].actorRole).toBe("coordinator"); + expect(deserialized[1].action).toBe("viewPlan"); + }); +}); diff --git a/tests/caring-contacts-caseload-query.test.ts b/tests/caring-contacts-caseload-query.test.ts new file mode 100644 index 0000000000..8c3d305a02 --- /dev/null +++ b/tests/caring-contacts-caseload-query.test.ts @@ -0,0 +1,180 @@ +// tests/caring-contacts-caseload-query.test.ts +import { describe, expect, it } from "vitest"; + +import { paginateCaseload, queryCaseload, type CaseloadRecord } from "@/lib/caring-contacts/caseload-query"; + +function mockPatients(count: number): CaseloadRecord[] { + return Array.from({ length: count }, (_, i) => ({ + patientId: `PATIENT-${i + 1}`, + patientName: `Patient ${i + 1}`, + planId: `PLAN-${i + 1}`, + referralId: `REF-${i + 1}`, + state: i % 2 === 0 ? "active" : "draft", + })); +} + +describe("Task 2 #LM33K2: Caseload pagination boundary condition", () => { + it("does not drop the patient on an exact page-size count (totalCount === pageSize)", () => { + // 10 patients with pageSize 10 + const patients = mockPatients(10); + const result = paginateCaseload(patients, { page: 1, pageSize: 10 }); + + expect(result.totalCount).toBe(10); + expect(result.items).toHaveLength(10); + expect(result.items[9].patientId).toBe("PATIENT-10"); + expect(result.totalPages).toBe(1); + expect(result.hasNextPage).toBe(false); + expect(result.hasPreviousPage).toBe(false); + }); + + it("does not drop the last patient on a multi-page exact boundary (e.g. 20 patients, pageSize 10)", () => { + const patients = mockPatients(20); + + // Page 1 + const page1 = paginateCaseload(patients, { page: 1, pageSize: 10 }); + expect(page1.items).toHaveLength(10); + expect(page1.items[0].patientId).toBe("PATIENT-1"); + expect(page1.items[9].patientId).toBe("PATIENT-10"); + expect(page1.hasNextPage).toBe(true); + + // Page 2 + const page2 = paginateCaseload(patients, { page: 2, pageSize: 10 }); + expect(page2.items).toHaveLength(10); + expect(page2.items[0].patientId).toBe("PATIENT-11"); + expect(page2.items[9].patientId).toBe("PATIENT-20"); + expect(page2.hasNextPage).toBe(false); + expect(page2.hasPreviousPage).toBe(true); + }); + + it("handles exact count when pageSize is 1", () => { + const patients = mockPatients(1); + const result = paginateCaseload(patients, { page: 1, pageSize: 1 }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].patientId).toBe("PATIENT-1"); + expect(result.totalPages).toBe(1); + expect(result.hasNextPage).toBe(false); + }); + + it("handles empty items array cleanly", () => { + const result = paginateCaseload([], { page: 1, pageSize: 10 }); + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.totalPages).toBe(1); + expect(result.hasNextPage).toBe(false); + expect(result.hasPreviousPage).toBe(false); + }); + + it("handles pagination beyond the last page safely (adversarial check)", () => { + const patients = mockPatients(15); // totalPages = 2 + const result = paginateCaseload(patients, { page: 5, pageSize: 10 }); + + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(15); + expect(result.page).toBe(5); + expect(result.totalPages).toBe(2); + expect(result.hasNextPage).toBe(false); + expect(result.hasPreviousPage).toBe(true); + }); + + it("handles totalCount + 1 boundary correctly (e.g. 11 patients, pageSize 10)", () => { + const patients = mockPatients(11); + + // Page 1 should have 10 patients + const page1 = paginateCaseload(patients, { page: 1, pageSize: 10 }); + expect(page1.items).toHaveLength(10); + expect(page1.totalPages).toBe(2); + expect(page1.hasNextPage).toBe(true); + expect(page1.hasPreviousPage).toBe(false); + + // Page 2 should have the remaining 1 patient (PATIENT-11) + const page2 = paginateCaseload(patients, { page: 2, pageSize: 10 }); + expect(page2.items).toHaveLength(1); + expect(page2.items[0].patientId).toBe("PATIENT-11"); + expect(page2.hasNextPage).toBe(false); + expect(page2.hasPreviousPage).toBe(true); + + // Page 3 is out of bounds + const page3 = paginateCaseload(patients, { page: 3, pageSize: 10 }); + expect(page3.items).toHaveLength(0); + expect(page3.hasNextPage).toBe(false); + expect(page3.hasPreviousPage).toBe(true); + }); + + it("handles out-of-bounds page request on empty dataset without false hasPreviousPage", () => { + const result = paginateCaseload([], { page: 5, pageSize: 10 }); + expect(result.items).toEqual([]); + expect(result.totalCount).toBe(0); + expect(result.totalPages).toBe(1); + expect(result.hasNextPage).toBe(false); + expect(result.hasPreviousPage).toBe(false); + }); + + it("normalizes negative or zero page / pageSize", () => { + const patients = mockPatients(5); + const result = paginateCaseload(patients, { page: -1, pageSize: 0 }); + + expect(result.page).toBe(1); + expect(result.pageSize).toBe(10); // fallback to default + expect(result.items).toHaveLength(5); + }); + + it("normalizes fractional page numbers and fractional page sizes safely", () => { + const patients = mockPatients(25); + + // Fractional page < 1 (e.g. 0.5) must normalize to 1, not 0 + const subOnePage = paginateCaseload(patients, { page: 0.5, pageSize: 10 }); + expect(subOnePage.page).toBe(1); + expect(subOnePage.items).toHaveLength(10); + expect(subOnePage.items[0].patientId).toBe("PATIENT-1"); + + // Fractional page > 1 (e.g. 2.7) floors to page 2 + const fractionalPage = paginateCaseload(patients, { page: 2.7, pageSize: 10 }); + expect(fractionalPage.page).toBe(2); + expect(fractionalPage.items).toHaveLength(10); + expect(fractionalPage.items[0].patientId).toBe("PATIENT-11"); + + // Fractional pageSize < 1 (e.g. 0.5) must fallback to default, avoiding division by zero / Infinity + const subOnePageSize = paginateCaseload(patients, { page: 1, pageSize: 0.5 }); + expect(subOnePageSize.pageSize).toBe(10); + expect(Number.isFinite(subOnePageSize.totalPages)).toBe(true); + expect(subOnePageSize.totalPages).toBe(3); + + // Fractional pageSize >= 1 (e.g. 10.9) floors to 10 + const fractionalPageSize = paginateCaseload(patients, { page: 1, pageSize: 10.9 }); + expect(fractionalPageSize.pageSize).toBe(10); + expect(fractionalPageSize.items).toHaveLength(10); + }); +}); + +describe("queryCaseload search and filtering", () => { + const patients: CaseloadRecord[] = [ + { patientId: "P-01", patientName: "Alice Walker", planId: "PLAN-A", state: "active" }, + { patientId: "P-02", patientName: "Bob Smith", planId: "PLAN-B", state: "draft" }, + { patientId: "P-03", patientName: "Charlie Brown", planId: "PLAN-C", state: "active" }, + ]; + + it("filters by patient name", () => { + const result = queryCaseload(patients, { query: "alice" }); + expect(result.totalCount).toBe(1); + expect(result.items[0].patientName).toBe("Alice Walker"); + }); + + it("filters by synthetic ID", () => { + const result = queryCaseload(patients, { query: "PLAN-B" }); + expect(result.totalCount).toBe(1); + expect(result.items[0].patientId).toBe("P-02"); + }); + + it("filters by plan state", () => { + const result = queryCaseload(patients, { state: "active" }); + expect(result.totalCount).toBe(2); + expect(result.items.map((i) => i.patientId)).toEqual(["P-01", "P-03"]); + }); + + it("handles empty search query returning all matching state records", () => { + const result = queryCaseload(patients, { query: " ", state: "all" }); + expect(result.totalCount).toBe(3); + expect(result.items).toHaveLength(3); + }); +}); diff --git a/tests/caring-contacts-draft-store.test.ts b/tests/caring-contacts-draft-store.test.ts new file mode 100644 index 0000000000..8854d41d6c --- /dev/null +++ b/tests/caring-contacts-draft-store.test.ts @@ -0,0 +1,189 @@ +// tests/caring-contacts-draft-store.test.ts +import { describe, expect, it } from "vitest"; + +import { DraftConcurrencyError, DraftStore } from "@/lib/caring-contacts/draft-store"; + +describe("Task 5 #M6P1QQ: Draft store optimistic locking and concurrent tab protection", () => { + it("creates a new draft initialized at version 1", () => { + const store = new DraftStore(); + const draft = store.saveDraft({ + draftId: "draft-1", + planId: "plan-100", + contactId: "contact-1", + authorId: "clinician-1", + content: "Hello from the care team.", + }); + + expect(draft.draftId).toBe("draft-1"); + expect(draft.version).toBe(1); + expect(draft.content).toBe("Hello from the care team."); + expect(draft.createdAt).toBeDefined(); + expect(draft.updatedAt).toBe(draft.createdAt); + }); + + it("updates a draft when expectedVersion matches and increments version", () => { + const store = new DraftStore(); + store.saveDraft({ + draftId: "draft-1", + planId: "plan-100", + authorId: "clinician-1", + content: "Original draft.", + }); + + const updated = store.saveDraft({ + draftId: "draft-1", + planId: "plan-100", + authorId: "clinician-1", + content: "Updated draft.", + expectedVersion: 1, + }); + + expect(updated.version).toBe(2); + expect(updated.content).toBe("Updated draft."); + }); + + it("prevents stale draft overwrite on concurrent tab edit (simulated race condition)", () => { + const store = new DraftStore(); + + // 1. Clinician opens draft in Tab A and Tab B (version 1) + const initial = store.saveDraft({ + draftId: "draft-shared", + planId: "plan-100", + authorId: "clinician-1", + content: "Initial draft message", + }); + expect(initial.version).toBe(1); + + // 2. Tab A edits and saves successfully (expectedVersion = 1 -> version becomes 2) + const tabASave = store.saveDraft({ + draftId: "draft-shared", + planId: "plan-100", + authorId: "clinician-1", + content: "Content edited in Tab A", + expectedVersion: 1, + }); + expect(tabASave.version).toBe(2); + + // 3. Tab B (which still holds version 1 in memory) attempts to save + expect(() => { + store.saveDraft({ + draftId: "draft-shared", + planId: "plan-100", + authorId: "clinician-1", + content: "Content edited in Tab B (stale)", + expectedVersion: 1, // Stale! Current is 2 + }); + }).toThrow(DraftConcurrencyError); + + // 4. Verify the error carries conflict details + try { + store.saveDraft({ + draftId: "draft-shared", + planId: "plan-100", + authorId: "clinician-1", + content: "Content edited in Tab B (stale)", + expectedVersion: 1, + }); + } catch (err) { + expect(err).toBeInstanceOf(DraftConcurrencyError); + const concurrencyErr = err as DraftConcurrencyError; + expect(concurrencyErr.expectedVersion).toBe(1); + expect(concurrencyErr.currentVersion).toBe(2); + expect(concurrencyErr.currentDraft?.content).toBe("Content edited in Tab A"); + } + + // 5. Confirm Tab A's content was not overwritten + const current = store.getDraft("draft-shared"); + expect(current?.version).toBe(2); + expect(current?.content).toBe("Content edited in Tab A"); + }); + + it("throws DraftConcurrencyError if expectedVersion is omitted on update", () => { + const store = new DraftStore(); + store.saveDraft({ + draftId: "draft-2", + planId: "plan-200", + authorId: "clinician-1", + content: "Initial content", + }); + + // Omitted expectedVersion on an existing draft must throw + expect(() => { + store.saveDraft({ + draftId: "draft-2", + planId: "plan-200", + authorId: "clinician-1", + content: "Blind update without version check", + }); + }).toThrow(DraftConcurrencyError); + }); + + it("enforces version checks on deletion", () => { + const store = new DraftStore(); + store.saveDraft({ + draftId: "draft-del", + planId: "plan-300", + authorId: "clinician-1", + content: "To be deleted", + }); + + // Stale deletion check + expect(() => { + store.deleteDraft("draft-del", 99); + }).toThrow(DraftConcurrencyError); + + // Correct deletion + const deleted = store.deleteDraft("draft-del", 1); + expect(deleted).toBe(true); + expect(store.getDraft("draft-del")).toBeNull(); + }); + + it("lists drafts by planId", () => { + const store = new DraftStore(); + store.saveDraft({ draftId: "d1", planId: "plan-A", authorId: "c1", content: "Msg 1" }); + store.saveDraft({ draftId: "d2", planId: "plan-A", authorId: "c1", content: "Msg 2" }); + store.saveDraft({ draftId: "d3", planId: "plan-B", authorId: "c1", content: "Msg 3" }); + + const draftsA = store.listDraftsForPlan("plan-A"); + expect(draftsA).toHaveLength(2); + expect(draftsA.map((d) => d.draftId)).toEqual(["d1", "d2"]); + }); + + it("preserves clinician attemptedContent on conflict and prevents resurrecting deleted drafts", () => { + const store = new DraftStore(); + store.saveDraft({ + draftId: "draft-deleted", + planId: "plan-A", + authorId: "c1", + content: "Original note", + }); + + // Clinician 1 deletes the draft + store.deleteDraft("draft-deleted", 1); + expect(store.getDraft("draft-deleted")).toBeNull(); + + // Clinician 2 in Tab B attempts to save with expectedVersion 1 and new content + let caughtError: DraftConcurrencyError | null = null; + try { + store.saveDraft({ + draftId: "draft-deleted", + planId: "plan-A", + authorId: "c2", + content: "Care plan vital update in Tab B", + expectedVersion: 1, + }); + } catch (err) { + caughtError = err as DraftConcurrencyError; + } + + expect(caughtError).not.toBeNull(); + expect(caughtError?.draftId).toBe("draft-deleted"); + expect(caughtError?.currentDraft).toBeNull(); + expect(caughtError?.currentVersion).toBeNull(); + // Preserves clinician's attempted edits for copy/recovery + expect(caughtError?.attemptedContent).toBe("Care plan vital update in Tab B"); + + // The deleted draft must NOT be resurrected + expect(store.getDraft("draft-deleted")).toBeNull(); + }); +}); diff --git a/tests/caring-contacts-plan-status-toggle.dom.test.tsx b/tests/caring-contacts-plan-status-toggle.dom.test.tsx new file mode 100644 index 0000000000..5eeb10ead9 --- /dev/null +++ b/tests/caring-contacts-plan-status-toggle.dom.test.tsx @@ -0,0 +1,214 @@ +// tests/caring-contacts-plan-status-toggle.dom.test.tsx +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PlanStatusToggle } from "@/components/caring-contacts/workspace/plan-status-toggle"; + +describe("Task 6 #99W2X1: Inactive contact plan status toggle confirmation modal barrier", () => { + afterEach(() => { + cleanup(); + }); + + it("renders active toggle switch when currentStatus is active", () => { + const onStatusChange = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("aria-checked", "true"); + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("requires confirmation modal barrier when transitioning active plan to inactive", async () => { + const user = userEvent.setup(); + const onStatusChange = vi.fn(); + + render( + , + ); + + // Click toggle while active + const toggle = screen.getByRole("switch"); + await user.click(toggle); + + // Must NOT transition immediately + expect(onStatusChange).not.toHaveBeenCalled(); + + // Confirmation dialog barrier MUST be visible + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(screen.getByText("Confirm Plan Deactivation")).toBeInTheDocument(); + expect( + screen.getByText(/All scheduled suicide-prevention outreach and automated messages will be suspended/), + ).toBeInTheDocument(); + + // Cancel deactivation + const cancelButton = screen.getByTestId("cancel-inactivation-button"); + await user.click(cancelButton); + + // Dialog closes without transitioning + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(onStatusChange).not.toHaveBeenCalled(); + }); + + it("proceeds with deactivation when clinician confirms in modal barrier", async () => { + const user = userEvent.setup(); + const onStatusChange = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click(screen.getByRole("switch")); + + // Enter clinical reason + const reasonInput = screen.getByLabelText(/Clinical Reason/i); + await user.type(reasonInput, "Patient readmitted to acute inpatient"); + + // Click confirm + const confirmButton = screen.getByTestId("confirm-inactivation-button"); + await user.click(confirmButton); + + expect(onStatusChange).toHaveBeenCalledWith("inactive", "Patient readmitted to acute inpatient"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("transitions directly to active when currently inactive without modal barrier", async () => { + const user = userEvent.setup(); + const onStatusChange = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + const toggle = screen.getByRole("switch"); + expect(toggle).toHaveAttribute("aria-checked", "false"); + + await user.click(toggle); + + // No modal required for reactivation + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(onStatusChange).toHaveBeenCalledWith("active"); + }); + + it("dismisses confirmation dialog on Escape key and clears entered reason", async () => { + const user = userEvent.setup(); + const onStatusChange = vi.fn(); + + render( + , + ); + + const toggle = screen.getByRole("switch"); + await user.click(toggle); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + const input = screen.getByLabelText(/Clinical Reason/i); + await user.type(input, "Temporary pause note"); + + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(onStatusChange).not.toHaveBeenCalled(); + + // Reopen dialog: draft reason should be cleared, not stale + await user.click(toggle); + expect(screen.getByLabelText(/Clinical Reason/i)).toHaveValue(""); + }); + + it("confirms deactivation when pressing Enter inside the reason input", async () => { + const user = userEvent.setup(); + const onStatusChange = vi.fn().mockResolvedValue(undefined); + + render( + , + ); + + await user.click(screen.getByRole("switch")); + + const input = screen.getByLabelText(/Clinical Reason/i); + await user.type(input, "Clinician pressed Enter{Enter}"); + + expect(onStatusChange).toHaveBeenCalledWith("inactive", "Clinician pressed Enter"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("manages focus safely: Cancel is focused initially, and focus restores to toggle on close", async () => { + const user = userEvent.setup(); + const onStatusChange = vi.fn(); + + render( + , + ); + + const toggle = screen.getByRole("switch"); + await user.click(toggle); + + // Initial focus MUST be on Cancel button for clinician safety (never default to destructive confirmation) + const cancelButton = screen.getByTestId("cancel-inactivation-button"); + expect(document.activeElement).toBe(cancelButton); + + // Cancel and verify focus returns to toggle switch + await user.click(cancelButton); + expect(document.activeElement).toBe(toggle); + }); + + it("does not trigger when disabled", async () => { + const user = userEvent.setup(); + const onStatusChange = vi.fn(); + + render( + , + ); + + const toggle = screen.getByRole("switch"); + expect(toggle).toBeDisabled(); + await user.click(toggle); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(onStatusChange).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/caring-contacts-retry-queue.test.ts b/tests/caring-contacts-retry-queue.test.ts new file mode 100644 index 0000000000..78351f34a5 --- /dev/null +++ b/tests/caring-contacts-retry-queue.test.ts @@ -0,0 +1,256 @@ +// tests/caring-contacts-retry-queue.test.ts +import { describe, expect, it } from "vitest"; + +import { + calculateNextRetryTime, + calculateRetryDelayMs, + MAX_RETRY_ATTEMPTS, + NotificationRetryQueue, + RETRY_BACKOFF_LADDER_MS, +} from "@/lib/caring-contacts/retry-queue"; + +describe("Task 4 #8K9W2B: Notification retry queue backoff ladder", () => { + it("synchronizes retry scheduler with the documented exponential ladder (1m, 5m, 15m, 1h, 6h)", () => { + expect(MAX_RETRY_ATTEMPTS).toBe(5); + expect(RETRY_BACKOFF_LADDER_MS).toEqual([ + 60_000, // 1m + 300_000, // 5m + 900_000, // 15m + 3_600_000, // 1h + 21_600_000, // 6h + ]); + }); + + it("handles zero-retry queue records (attemptCount = 0) cleanly", () => { + // Zero-retry records must receive the 1m delay + const delay = calculateRetryDelayMs(0); + expect(delay).toBe(60_000); + + const now = new Date("2026-09-07T10:00:00.000Z"); + const nextTime = calculateNextRetryTime(0, now); + expect(nextTime?.toISOString()).toBe("2026-09-07T10:01:00.000Z"); + }); + + it("calculates delay correctly for each step of the ladder", () => { + expect(calculateRetryDelayMs(0)).toBe(60_000); // 1m + expect(calculateRetryDelayMs(1)).toBe(300_000); // 5m + expect(calculateRetryDelayMs(2)).toBe(900_000); // 15m + expect(calculateRetryDelayMs(3)).toBe(3_600_000); // 1h + expect(calculateRetryDelayMs(4)).toBe(21_600_000); // 6h + }); + + it("returns null when max attempts (5) are exhausted", () => { + expect(calculateRetryDelayMs(5)).toBeNull(); + expect(calculateRetryDelayMs(6)).toBeNull(); + }); + + it("enqueues and advances retry queue items through failures to dead-letter", () => { + const queue = new NotificationRetryQueue(); + const t0 = new Date("2026-09-07T08:00:00.000Z"); + + // Initial enqueue (attempt 0 -> next retry at +1m) + const item = queue.enqueue({ + id: "retry-1", + notificationId: "notif-1", + planId: "plan-1", + contactId: "contact-1", + recipientId: "recip-1", + now: t0, + }); + + expect(item.attemptCount).toBe(0); + expect(item.status).toBe("pending"); + expect(item.nextRetryAt).toBe("2026-09-07T08:01:00.000Z"); // +1m + + // Attempt 1 fails at 08:01 -> next retry at +5m (08:06) + const t1 = new Date("2026-09-07T08:01:00.000Z"); + const fail1 = queue.recordFailure("retry-1", "network timeout", t1); + expect(fail1.attemptCount).toBe(1); + expect(fail1.status).toBe("pending"); + expect(fail1.nextRetryAt).toBe("2026-09-07T08:06:00.000Z"); + + // Attempt 2 fails at 08:06 -> next retry at +15m (08:21) + const t2 = new Date("2026-09-07T08:06:00.000Z"); + const fail2 = queue.recordFailure("retry-1", "network timeout", t2); + expect(fail2.attemptCount).toBe(2); + expect(fail2.nextRetryAt).toBe("2026-09-07T08:21:00.000Z"); + + // Attempt 3 fails at 08:21 -> next retry at +1h (09:21) + const t3 = new Date("2026-09-07T08:21:00.000Z"); + const fail3 = queue.recordFailure("retry-1", "network timeout", t3); + expect(fail3.attemptCount).toBe(3); + expect(fail3.nextRetryAt).toBe("2026-09-07T09:21:00.000Z"); + + // Attempt 4 fails at 09:21 -> next retry at +6h (15:21) + const t4 = new Date("2026-09-07T09:21:00.000Z"); + const fail4 = queue.recordFailure("retry-1", "network timeout", t4); + expect(fail4.attemptCount).toBe(4); + expect(fail4.nextRetryAt).toBe("2026-09-07T15:21:00.000Z"); + + // Attempt 5 fails at 15:21 -> dead_letter, no further retries + const t5 = new Date("2026-09-07T15:21:00.000Z"); + const fail5 = queue.recordFailure("retry-1", "upstream gateway error", t5); + expect(fail5.attemptCount).toBe(5); + expect(fail5.status).toBe("dead_letter"); + expect(fail5.nextRetryAt).toBeNull(); + }); + + it("marks successful retries completed", () => { + const queue = new NotificationRetryQueue(); + const t0 = new Date("2026-09-07T08:00:00.000Z"); + + queue.enqueue({ + id: "retry-success", + notificationId: "notif-2", + planId: "plan-2", + contactId: "contact-2", + recipientId: "recip-2", + now: t0, + }); + + const success = queue.recordSuccess("retry-success", new Date("2026-09-07T08:01:00.000Z")); + expect(success.status).toBe("completed"); + expect(success.nextRetryAt).toBeNull(); + expect(success.completedAt).toBe("2026-09-07T08:01:00.000Z"); + }); + + it("filters pending retries due at given time", () => { + const queue = new NotificationRetryQueue(); + const t0 = new Date("2026-09-07T08:00:00.000Z"); + + queue.enqueue({ + id: "item-1", + notificationId: "n1", + planId: "p1", + contactId: "c1", + recipientId: "r1", + now: t0, // next retry at 08:01 + }); + + // Before 08:01 -> no pending retries + expect(queue.getPendingRetries(new Date("2026-09-07T08:00:30.000Z"))).toHaveLength(0); + + // At 08:01 -> item-1 is due + const pending = queue.getPendingRetries(new Date("2026-09-07T08:01:00.000Z")); + expect(pending).toHaveLength(1); + expect(pending[0].id).toBe("item-1"); + }); + + it("immediately marks records enqueued with attemptCount >= 5 as dead_letter", () => { + const queue = new NotificationRetryQueue(); + const item = queue.enqueue({ + id: "already-exhausted", + notificationId: "n-exhausted", + planId: "p-1", + contactId: "c-1", + recipientId: "r-1", + attemptCount: 5, + }); + + expect(item.attemptCount).toBe(5); + expect(item.status).toBe("dead_letter"); + expect(item.nextRetryAt).toBeNull(); + // Must not be returned in pending retries + expect(queue.getPendingRetries(new Date("2099-01-01"))).toHaveLength(0); + }); + + it("clamps negative attemptCount on enqueue to 0 to prevent excessive retries", () => { + const queue = new NotificationRetryQueue(); + const item = queue.enqueue({ + id: "neg-attempt", + notificationId: "n-neg", + planId: "p-1", + contactId: "c-1", + recipientId: "r-1", + attemptCount: -5, + }); + + expect(item.attemptCount).toBe(0); + expect(item.status).toBe("pending"); + expect(item.nextRetryAt).not.toBeNull(); + }); + + it("handles non-integer and NaN attempt counts in calculateRetryDelayMs", () => { + // Non-integer floors to matching ladder index + expect(calculateRetryDelayMs(1.9)).toBe(300_000); // index 1 (5m) + expect(calculateRetryDelayMs(3.2)).toBe(3_600_000); // index 3 (1h) + + // NaN or negative fallback to first ladder step + expect(calculateRetryDelayMs(NaN)).toBe(60_000); + expect(calculateRetryDelayMs(-1)).toBe(60_000); + + // Greater than or equal to max attempts returns null + expect(calculateRetryDelayMs(5.5)).toBeNull(); + }); + + it("preserves dead_letter state and prevents rescheduling if failed after max attempts", () => { + const queue = new NotificationRetryQueue(); + const t0 = new Date("2026-09-07T10:00:00.000Z"); + + queue.enqueue({ + id: "dead-item", + notificationId: "n-dead", + planId: "p-1", + contactId: "c-1", + recipientId: "r-1", + attemptCount: 4, + now: t0, + }); + + // 5th attempt fails -> dead letter + const dead = queue.recordFailure("dead-item", "fail 5", new Date("2026-09-07T10:05:00.000Z")); + expect(dead.status).toBe("dead_letter"); + expect(dead.nextRetryAt).toBeNull(); + + // Calling recordFailure again on an already dead-lettered item does not reschedule + const postDead = queue.recordFailure("dead-item", "subsequent error", new Date("2026-09-07T10:10:00.000Z")); + expect(postDead.status).toBe("dead_letter"); + expect(postDead.nextRetryAt).toBeNull(); + expect(postDead.lastError).toBe("subsequent error"); + }); + + it("refuses to record failure on an already completed item", () => { + const queue = new NotificationRetryQueue(); + queue.enqueue({ + id: "completed-item", + notificationId: "n-comp", + planId: "p-1", + contactId: "c-1", + recipientId: "r-1", + }); + + queue.recordSuccess("completed-item"); + + expect(() => { + queue.recordFailure("completed-item", "late arrival failure"); + }).toThrow(/Cannot record failure on completed retry item/); + }); + + it("guards against clock drift backwards and negative base dates", () => { + const queue = new NotificationRetryQueue(); + const t0 = new Date("2026-09-07T12:00:00.000Z"); + + queue.enqueue({ + id: "clock-drift-item", + notificationId: "n-drift", + planId: "p-1", + contactId: "c-1", + recipientId: "r-1", + now: t0, + }); + + // Clock steps backwards to 11:00:00 (1 hour before creation) + const driftedPast = new Date("2026-09-07T11:00:00.000Z"); + const updated = queue.recordFailure("clock-drift-item", "error with skewed clock", driftedPast); + + // Scheduled retry must NOT be scheduled in the past relative to createdAt + expect(new Date(updated.nextRetryAt!).getTime()).toBeGreaterThanOrEqual( + t0.getTime() + 300_000, // at least t0 + 5m + ); + + // Negative baseDate is normalized without crashing + const nextWithNeg = calculateNextRetryTime(0, new Date(-1000)); + expect(nextWithNeg).not.toBeNull(); + expect(nextWithNeg!.getTime()).toBeGreaterThan(0); + }); +}); diff --git a/tests/caring-contacts-search-privacy.test.ts b/tests/caring-contacts-search-privacy.test.ts new file mode 100644 index 0000000000..bf183c19a9 --- /dev/null +++ b/tests/caring-contacts-search-privacy.test.ts @@ -0,0 +1,141 @@ +// tests/caring-contacts-search-privacy.test.ts +import { beforeEach, describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; + +import { + clearSearchFilterTokenStore, + createSearchFilterToken, + isSearchFilterToken, + resolveSearchFilterToken, +} from "@/lib/caring-contacts/caseload-search-token"; +import { + PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, + PATIENTS_DIRECTORY_RECOGNISED_PARAMS, + readPatientsDirectoryAddress, +} from "@/lib/caring-contacts/patients-directory-filter"; +import { POST as searchRoutePost } from "@/app/api/caring-contacts/patients/search/route"; + +describe("Task 1 #HDCF2B: Patient privacy in caseload search (no PHI in URL / access logs)", () => { + beforeEach(() => { + clearSearchFilterTokenStore(); + }); + + it("generates an opaque session filter token that does NOT expose raw patient names or base64 PHI", () => { + const rawName = "Jordan Nguyen"; + const token = createSearchFilterToken(rawName); + + expect(token).toMatch(/^sft_[a-f0-9]{32}$/); + // Raw patient name must NOT appear in the token string in plaintext or trivial encoding + expect(token).not.toContain("Jordan"); + expect(token).not.toContain("Nguyen"); + expect(token).not.toContain("jordan"); + expect(isSearchFilterToken(token)).toBe(true); + + // Decoding the token string as base64 / utf8 never yields patient name + const raw = token.slice(4); + expect(Buffer.from(raw, "base64url").toString("utf8")).not.toContain("Jordan"); + + // Resolves back to the query via server-side session store + const resolved = resolveSearchFilterToken(token); + expect(resolved).toBe(rawName); + }); + + it("handles empty or whitespace query cleanly", () => { + expect(createSearchFilterToken("")).toBe(""); + expect(createSearchFilterToken(" ")).toBe(""); + expect(resolveSearchFilterToken("")).toBeNull(); + expect(resolveSearchFilterToken(null)).toBeNull(); + expect(resolveSearchFilterToken("sft_invalid-garbage")).toBeNull(); + }); + + it("enforces TTL expiration on search tokens", () => { + const rawName = "Eleanor Vance"; + const baseTime = 1700000000000; + const ttlMs = 60 * 1000; // 1 minute + + const token = createSearchFilterToken(rawName, { ttlMs, now: baseTime }); + expect(resolveSearchFilterToken(token, { now: baseTime + 30 * 1000 })).toBe(rawName); + + // After TTL, token must expire and return null + expect(resolveSearchFilterToken(token, { now: baseTime + 65 * 1000 })).toBeNull(); + }); + + it("recognises valid filterToken in address without triggering dropped-parameter redirect", () => { + expect(PATIENTS_DIRECTORY_RECOGNISED_PARAMS).toContain(PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM); + + const token = createSearchFilterToken("Jordan Nguyen"); + const address = readPatientsDirectoryAddress({ + state: "active", + [PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]: token, + }); + + // Valid filterToken is recognised, so droppedUnrecognisedParams must be false + expect(address.droppedUnrecognisedParams).toBe(false); + expect(address.searchNotApplied).toBe(false); + expect(address.searchQuery).toBe("Jordan Nguyen"); + expect(address.canonicalQuery).toContain(`filterToken=${encodeURIComponent(token)}`); + // Crucially, the canonical query does not contain the raw name + expect(address.canonicalQuery).not.toContain("Jordan"); + expect(address.canonicalQuery).not.toContain("Nguyen"); + }); + + it("drops expired or corrupted filterToken and sets searchNotApplied to clean the URL", () => { + const corruptedToken = "sft_nonexistent_or_expired_12345"; + const address = readPatientsDirectoryAddress({ + state: "active", + [PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]: corruptedToken, + }); + + // Expired/corrupted token must be dropped from canonical query + expect(address.droppedUnrecognisedParams).toBe(true); + expect(address.searchNotApplied).toBe(true); + expect(address.searchQuery).toBeUndefined(); + expect(address.canonicalQuery).not.toContain("filterToken"); + expect(address.canonicalQuery).toBe("state=active&searchNotApplied=1"); + }); + + it("drops raw GET query parameters (?q=, ?name=, ?search=) to prevent PHI in URL history", () => { + const rawParams = [ + { q: "Jordan Nguyen" }, + { name: "Jordan Nguyen" }, + { search: "Jordan Nguyen" }, + { patient: "Jordan Nguyen" }, + ]; + + for (const params of rawParams) { + const address = readPatientsDirectoryAddress(params); + expect(address.droppedUnrecognisedParams).toBe(true); + expect(address.searchNotApplied).toBe(true); + // Canonical query must be clean of the unrecognised parameter + expect(address.canonicalQuery).not.toContain("Jordan"); + expect(address.canonicalQuery).not.toContain("Nguyen"); + expect(address.canonicalQuery).toBe("searchNotApplied=1"); + } + }); + + it("POST /api/caring-contacts/patients/search receives body payload and returns filterToken", async () => { + const request = new NextRequest("http://localhost/api/caring-contacts/patients/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + query: "Sarah Connor", + state: "active", + }), + }); + + const response = await searchRoutePost(request); + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json.hasFilter).toBe(true); + expect(json.filterToken).toMatch(/^sft_[a-f0-9]{32}$/); + expect(json.filterToken).not.toContain("Sarah"); + expect(json.destination).toContain("state=active"); + expect(json.destination).toContain("filterToken="); + expect(json.destination).not.toContain("Sarah"); + expect(json.destination).not.toContain("Connor"); + + // Token returned from endpoint resolves to the searched patient + expect(resolveSearchFilterToken(json.filterToken)).toBe("Sarah Connor"); + }); +}); From 32c3549d7b962a9e5308b6878e88cbbc446ba60a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 07:53:03 +0000 Subject: [PATCH 02/15] fix(caring-contacts): stop node:crypto from reaching the patients-directory client bundle CI Build failed with webpack's UnhandledSchemeError on node:crypto. The chain was: patients-directory-client.tsx ("use client") -> patients-directory-filter.ts -> caseload-search-token.ts (imports node:crypto for the sft_ token store), because patients-directory-filter.ts mixed a client-safe piece (state order, filter parsing) with the one server-only function that resolves a filter token, readPatientsDirectoryAddress. Split readPatientsDirectoryAddress and its PatientsDirectoryAddress type out into a new src/lib/caring-contacts/patients-directory-address.ts, marked `import "server-only"` (the existing repo convention for this exact class of module, e.g. caring-contacts-server/config.ts). patients-directory-filter.ts no longer imports caseload-search-token.ts at all, so it stays safely importable from the client component. Updated the two call sites (page.tsx, and the two test files that imported readPatientsDirectoryAddress) to the new module path; no behavioural change. Also regenerated data/repo-awareness-snapshot.json (npm run snapshot:repo-awareness), which was behind on the new POST /api/caring-contacts/patients/search route this PR already registered in site-map.md, fixing the check:repo-awareness-snapshot failure in Static PR checks. Verified: npm run build (full production build, webpack compiles clean), npm run typecheck, eslint on the changed files, and the PR's caring-contacts test files (116/116 passing, including caring-contacts-search-privacy.test.ts and caring-contacts-patients-directory.dom.test.tsx which exercise readPatientsDirectoryAddress directly). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L8jaqEKUUgnjPrQgR18igS --- data/repo-awareness-snapshot.json | 28 ++++++ src/app/caring-contacts/patients/page.tsx | 2 +- .../patients-directory-address.ts | 88 +++++++++++++++++++ .../patients-directory-filter.ts | 81 ++++------------- ...g-contacts-patients-directory.dom.test.tsx | 6 +- tests/caring-contacts-search-privacy.test.ts | 2 +- 6 files changed, 137 insertions(+), 70 deletions(-) create mode 100644 src/lib/caring-contacts/patients-directory-address.ts diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index bf47a706d1..3778b7fa24 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1471,6 +1471,10 @@ "path": "/api/registry/records", "file": "src/app/api/registry/records/route.ts" }, + { + "path": "/api/caring-contacts/patients/search", + "file": "src/app/api/caring-contacts/patients/search/route.ts" + }, { "path": "/api/caring-contacts/dispatches", "file": "src/app/api/caring-contacts/dispatches/route.ts" @@ -6424,6 +6428,14 @@ "outcome": "CHANGES REQUESTED / findings. No P0. Two confirmed P2 UX defects: (1) mobile presentation Compare tab is a self-link that drops `ids`/`q` and resets selection (1→default 4) + clears Query chip; (2) document “Browse by tag” / `searchByTag` navigates to `/?mode=documents&q=…` without `run=1`, so results do not run. P3: comparison disabled dropdown/filter controls lack `aria-describedby` placeholder contract; bare `/documents` 404 (no inbound links; `/documents/search` is canonical); phone mode-switcher truncates “Differenti…”. Deduped: `/tools` vs `/?mode=tools` remains #007; coming-soon density/favourites/forms remain #010. Phone docks flush `bottom:0` on sampled result routes; no document horizontal overflow at 390/768/1280 across 30 routes.", "checks": "`npm run workflow:design-sweep -- --write-evidence`; `npm run ensure` → http://localhost:4461 identity Clinical KB; Playwright HTTP+overflow matrix 30/30 no overflow; live Compare/tag proofs + phone route matrix; `npm run test:e2e:accessibility` 12/12; screenshots under `/opt/cursor/artifacts/screenshots/design-review-2026-07-24/`. No OpenAI/Supabase/GitHub/hosted CI/provider mutations. Added project subagent `.cursor/agents/design-review.md`." }, + { + "date": "2026-09-07", + "ref": "PR-2601", + "head": "01cfdbd9f314291640e5d39bc698a7fcc418b3b4", + "scope": "PR CI and review repair", + "outcome": "Fixed three unresolved governance findings and the deterministic forms sorting CI failure; focused calculator checks, lint and typecheck passed.", + "checks": "vitest calculators-governance-hardening (8); check:calculator-content; lint; typecheck; verify:pr-local offline stages through unit suite start" + }, { "date": "2026-07-14", "ref": "codex/global-answer-reliability", @@ -17440,6 +17452,14 @@ "outcome": "FIXED. Supersedes prior reviews after merging current-main PR #1469. No remaining P0-P2 findings; #107 is archived with executing jsdom state-matrix coverage, and the branch's existing changes remain intact.", "checks": "focused current-main state-matrix suite 2 files / 10 tests PASS; outstanding ledger 146 rows / 49 open / 97 archived PASS; branch-review ledger PASS; prior combined-tree verify:cheap 32 gates PASS" }, + { + "date": "2026-09-07", + "ref": "PR-2693", + "head": "85aad321b9a67eefa4dac2acb22552b8086df9c5", + "scope": "PR CI and review repair", + "outcome": "Verified complete 24-request reconciliation and fixed deterministic forms sorting CI expectation.", + "checks": "check:outstanding-issues; check:ledger-write-discipline; installed-lock parity; merge-tree clean" + }, { "date": "2026-07-28", "ref": "PR #1295 / `fix/audit-remediation-from-main`", @@ -21408,6 +21428,14 @@ "outcome": "Redundant: no patch-unique non-merge commits remain against `origin/main`; eligible for deletion when unreferenced.", "checks": "`git log --right-only --cherry-pick --no-merges origin/main...claude/mobile-search-bar-fix` returned empty." }, + { + "date": "2026-09-05", + "ref": "codex/calculators-governance-hardening (PR #2601)", + "head": "b755976a79bb8e0b203fd0ede882685f6804f1eb", + "scope": "Run PR sweep: CI fix + threads + drift", + "outcome": "before: PR required green but BEHIND main, 1 unresolved P2 thread (governance checker wiring test never exercised failure path). after: merged origin/main (clean, no conflicts), added test that runs the real checker script against corrupted fixture data and asserts nonzero exit + diagnostic, thread replied and resolved.", + "checks": "npx vitest run tests/calculators-governance-hardening.test.ts (6 passed); npm run typecheck (clean, recorded pass); npx prettier --write (unchanged); git merge-tree confirmed clean before merging origin/main. No provider-backed checks run." + }, { "date": "2026-08-30", "ref": "codex/smart-natural-search-current-main", diff --git a/src/app/caring-contacts/patients/page.tsx b/src/app/caring-contacts/patients/page.tsx index f949f96c16..e92ff5d56d 100644 --- a/src/app/caring-contacts/patients/page.tsx +++ b/src/app/caring-contacts/patients/page.tsx @@ -6,7 +6,7 @@ import { CARING_CONTACTS_ROUTES } from "@/lib/caring-contacts-routes"; import { auditedRead } from "@/lib/caring-contacts-server/handler"; import { isCaringContactsDemoEnabled, resolveDemoActor } from "@/lib/caring-contacts-server/session"; import { caringContactsStore } from "@/lib/caring-contacts-server/store"; -import { readPatientsDirectoryAddress } from "@/lib/caring-contacts/patients-directory-filter"; +import { readPatientsDirectoryAddress } from "@/lib/caring-contacts/patients-directory-address"; import { canPerformCaringContactAction } from "@/lib/caring-contacts/permissions"; import { READ_ACTIONS, type PatientNameProjection, type PlanRecord } from "@/lib/caring-contacts/repository"; import type { ServiceState } from "@/lib/caring-contacts/service-state"; diff --git a/src/lib/caring-contacts/patients-directory-address.ts b/src/lib/caring-contacts/patients-directory-address.ts new file mode 100644 index 0000000000..db52abe3a7 --- /dev/null +++ b/src/lib/caring-contacts/patients-directory-address.ts @@ -0,0 +1,88 @@ +// src/lib/caring-contacts/patients-directory-address.ts +// +// SERVER-ONLY. Reads the full caseload directory address, including resolving an obfuscated +// `sft_` session filter token (#HDCF2B) back into a search query via `caseload-search-token.ts`, +// which stores that lookup in memory and mints tokens with `node:crypto`. +// +// This is split out of `patients-directory-filter.ts` deliberately: that module is imported by +// `patients-directory-client.tsx`, a `"use client"` component, and a client bundle that reaches +// `node:crypto` fails webpack outright (`UnhandledSchemeError: Reading from "node:crypto" is not +// handled by plugins`). `import "server-only"` below turns that failure mode into a clear build +// error at the actual import site if this file is ever reached from a client component, rather +// than the confusing "why is Node core code in my browser bundle" trace this split replaces. +import "server-only"; + +import { resolveSearchFilterToken } from "./caseload-search-token"; +import { + parsePatientsDirectoryFilter, + PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, + PATIENTS_DIRECTORY_OVERLAY_PARAM, + PATIENTS_DIRECTORY_RECOGNISED_PARAMS, + PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM, + type PatientsDirectoryFilter, +} from "./patients-directory-filter"; +import { CARING_CONTACTS_STATE_PARAM } from "./workspace-address"; + +/** What the address says, and what it should be rewritten to. Never carries a dropped VALUE. */ +export type PatientsDirectoryAddress = { + filter: PatientsDirectoryFilter; + /** + * True when the address carried at least one parameter this route does not understand. A + * BOOLEAN, deliberately: not the name, not the value, not a count, not a length. Nothing that + * narrows what the dropped term was may travel any further than this function. + */ + droppedUnrecognisedParams: boolean; + /** True when the address records that a saved search term was dropped on the way here. */ + searchNotApplied: boolean; + /** + * The query string this address should have had: recognised parameters only, in a fixed order, + * `""` when there are none. It is built by NAMING what may be kept rather than by deleting what + * may not, so a dropped value has no path into it even by accident. + */ + canonicalQuery: string; + /** Resolved search query from an obfuscated session filter token (#HDCF2B), if present. */ + searchQuery?: string; + /** The obfuscated filter token itself, if present. */ + filterToken?: string; +}; + +/** + * Read the address, and say what it should be rewritten to. + * + * WHY IGNORING THE PARAMETER WAS NOT ENOUGH. Declining to honour `?q=` leaves the name in the + * address bar, and `overlayUrl()` in `workspace-overlays.tsx` copies EVERY existing parameter into + * each history entry it pushes -- so an ignored name was re-written into a fresh history entry + * every time a coordinator opened an overlay. Not reading a value is not the same as removing it, + * and on this page not reading it actively multiplied it. + */ +export function readPatientsDirectoryAddress( + searchParams: Readonly>, +): PatientsDirectoryAddress { + const filter = parsePatientsDirectoryFilter(searchParams); + const rawFilterToken = searchParams[PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]; + const filterToken = typeof rawFilterToken === "string" ? rawFilterToken : undefined; + const searchQuery = filterToken ? (resolveSearchFilterToken(filterToken) ?? undefined) : undefined; + const invalidToken = Boolean(filterToken && !searchQuery); + + const droppedUnrecognisedParams = + invalidToken || Object.keys(searchParams).some((key) => !PATIENTS_DIRECTORY_RECOGNISED_PARAMS.includes(key)); + const alreadyFlagged = typeof searchParams[PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM] === "string"; + const overlay = searchParams[PATIENTS_DIRECTORY_OVERLAY_PARAM]; + + // Built from named recognised values only. `searchParams` is never spread, filtered or copied + // into this, because a copy is how a value ends up somewhere nobody meant it to be. + const kept = new URLSearchParams(); + if (filter.state !== "all") kept.set(CARING_CONTACTS_STATE_PARAM, filter.state); + if (typeof overlay === "string") kept.set(PATIENTS_DIRECTORY_OVERLAY_PARAM, overlay); + if (filterToken && searchQuery) kept.set(PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, filterToken); + if (droppedUnrecognisedParams || alreadyFlagged) kept.set(PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM, "1"); + + return { + filter, + droppedUnrecognisedParams, + searchNotApplied: droppedUnrecognisedParams || alreadyFlagged, + canonicalQuery: kept.toString(), + searchQuery, + filterToken: searchQuery ? filterToken : undefined, + }; +} diff --git a/src/lib/caring-contacts/patients-directory-filter.ts b/src/lib/caring-contacts/patients-directory-filter.ts index 7e2778b79b..b2cf0f476b 100644 --- a/src/lib/caring-contacts/patients-directory-filter.ts +++ b/src/lib/caring-contacts/patients-directory-filter.ts @@ -1,4 +1,3 @@ -import { resolveSearchFilterToken } from "./caseload-search-token"; import type { PlanState } from "./model"; import { CARING_CONTACTS_OVERLAY_PARAM, @@ -6,6 +5,19 @@ import { CARING_CONTACTS_STATE_PARAM, } from "./workspace-address"; +/** + * CLIENT-SAFE BY CONSTRUCTION. + * + * `patients-directory-client.tsx` (a `"use client"` component) imports + * `PATIENTS_DIRECTORY_STATE_ORDER` and `PatientsDirectoryFilter` from this file, so nothing here + * may import `node:crypto` or anything that transitively does. `readPatientsDirectoryAddress` -- + * the one piece of this domain that resolves an obfuscated `sft_` token and therefore needs the + * server-only token store -- lives in `patients-directory-address.ts` instead, which imports the + * client-safe declarations below rather than the other way around. Keep it that way: pulling + * `caseload-search-token.ts` back into this file re-creates webpack's + * `UnhandledSchemeError: Reading from "node:crypto"` for every client bundle that reaches here. + */ + /** Every plan state, in lifecycle order, as the directory filter offers them. */ export const PATIENTS_DIRECTORY_STATE_ORDER: readonly PlanState[] = Object.freeze([ "draft", @@ -90,66 +102,7 @@ export const PATIENTS_DIRECTORY_RECOGNISED_PARAMS: readonly string[] = Object.fr PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, ]); -/** What the address says, and what it should be rewritten to. Never carries a dropped VALUE. */ -export type PatientsDirectoryAddress = { - filter: PatientsDirectoryFilter; - /** - * True when the address carried at least one parameter this route does not understand. A - * BOOLEAN, deliberately: not the name, not the value, not a count, not a length. Nothing that - * narrows what the dropped term was may travel any further than this function. - */ - droppedUnrecognisedParams: boolean; - /** True when the address records that a saved search term was dropped on the way here. */ - searchNotApplied: boolean; - /** - * The query string this address should have had: recognised parameters only, in a fixed order, - * `""` when there are none. It is built by NAMING what may be kept rather than by deleting what - * may not, so a dropped value has no path into it even by accident. - */ - canonicalQuery: string; - /** Resolved search query from an obfuscated session filter token (#HDCF2B), if present. */ - searchQuery?: string; - /** The obfuscated filter token itself, if present. */ - filterToken?: string; -}; - -/** - * Read the address, and say what it should be rewritten to. - * - * WHY IGNORING THE PARAMETER WAS NOT ENOUGH. Declining to honour `?q=` leaves the name in the - * address bar, and `overlayUrl()` in `workspace-overlays.tsx` copies EVERY existing parameter into - * each history entry it pushes -- so an ignored name was re-written into a fresh history entry - * every time a coordinator opened an overlay. Not reading a value is not the same as removing it, - * and on this page not reading it actively multiplied it. - */ -export function readPatientsDirectoryAddress( - searchParams: Readonly>, -): PatientsDirectoryAddress { - const filter = parsePatientsDirectoryFilter(searchParams); - const rawFilterToken = searchParams[PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]; - const filterToken = typeof rawFilterToken === "string" ? rawFilterToken : undefined; - const searchQuery = filterToken ? (resolveSearchFilterToken(filterToken) ?? undefined) : undefined; - const invalidToken = Boolean(filterToken && !searchQuery); - - const droppedUnrecognisedParams = - invalidToken || Object.keys(searchParams).some((key) => !PATIENTS_DIRECTORY_RECOGNISED_PARAMS.includes(key)); - const alreadyFlagged = typeof searchParams[PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM] === "string"; - const overlay = searchParams[PATIENTS_DIRECTORY_OVERLAY_PARAM]; - - // Built from named recognised values only. `searchParams` is never spread, filtered or copied - // into this, because a copy is how a value ends up somewhere nobody meant it to be. - const kept = new URLSearchParams(); - if (filter.state !== "all") kept.set(CARING_CONTACTS_STATE_PARAM, filter.state); - if (typeof overlay === "string") kept.set(PATIENTS_DIRECTORY_OVERLAY_PARAM, overlay); - if (filterToken && searchQuery) kept.set(PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, filterToken); - if (droppedUnrecognisedParams || alreadyFlagged) kept.set(PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM, "1"); - - return { - filter, - droppedUnrecognisedParams, - searchNotApplied: droppedUnrecognisedParams || alreadyFlagged, - canonicalQuery: kept.toString(), - searchQuery, - filterToken: searchQuery ? filterToken : undefined, - }; -} +// `PatientsDirectoryAddress` and `readPatientsDirectoryAddress` live in `patients-directory-address.ts`. +// That function resolves an obfuscated `sft_` filter token, which needs the server-only token store in +// `caseload-search-token.ts` (`node:crypto`) -- and this file must stay importable from +// `patients-directory-client.tsx`, a `"use client"` component. See the module note above. diff --git a/tests/caring-contacts-patients-directory.dom.test.tsx b/tests/caring-contacts-patients-directory.dom.test.tsx index d0253d9834..14729a8908 100644 --- a/tests/caring-contacts-patients-directory.dom.test.tsx +++ b/tests/caring-contacts-patients-directory.dom.test.tsx @@ -31,10 +31,8 @@ import { PatientsDirectory } from "@/components/caring-contacts/workspace/patien import { CARING_CONTACTS_ROUTES, patientRoute } from "@/lib/caring-contacts-routes"; import { contactId, pathwayVersionId, patientId, planId, referralId, teamId } from "@/lib/caring-contacts/ids"; import type { PlanState } from "@/lib/caring-contacts/model"; -import { - parsePatientsDirectoryFilter, - readPatientsDirectoryAddress, -} from "@/lib/caring-contacts/patients-directory-filter"; +import { readPatientsDirectoryAddress } from "@/lib/caring-contacts/patients-directory-address"; +import { parsePatientsDirectoryFilter } from "@/lib/caring-contacts/patients-directory-filter"; import type { PatientNameProjection, PlanRecord, StoredContact } from "@/lib/caring-contacts/repository"; const TEAM = teamId("demo-team"); diff --git a/tests/caring-contacts-search-privacy.test.ts b/tests/caring-contacts-search-privacy.test.ts index bf183c19a9..45a5106d9b 100644 --- a/tests/caring-contacts-search-privacy.test.ts +++ b/tests/caring-contacts-search-privacy.test.ts @@ -8,10 +8,10 @@ import { isSearchFilterToken, resolveSearchFilterToken, } from "@/lib/caring-contacts/caseload-search-token"; +import { readPatientsDirectoryAddress } from "@/lib/caring-contacts/patients-directory-address"; import { PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM, PATIENTS_DIRECTORY_RECOGNISED_PARAMS, - readPatientsDirectoryAddress, } from "@/lib/caring-contacts/patients-directory-filter"; import { POST as searchRoutePost } from "@/app/api/caring-contacts/patients/search/route"; From db902f22852caa1797dce36277d7b77cf721610a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:07:14 +0000 Subject: [PATCH 03/15] fix(caring-contacts): bind search filter tokens to the authorized session resolveSearchFilterToken previously accepted any bearer token and resolved it to the plaintext search query before authorization was checked, so a filterToken obtained from browser history, a Referer header, or an access log let anyone open the patients directory with the original searcher's query pre-filled, regardless of role. Tokens now store the minting actor's id and team alongside the query, and resolution requires the CURRENT actor to be that same actor AND to currently hold viewPatientRecord (READ_ACTIONS.patientName) -- the same capability the page already checks for mayViewPatientNames. A token replayed by anyone else, or redeemed after the grant is gone, resolves to null exactly like an expired token. readPatientsDirectoryAddress and the POST search route now thread the resolved actor through; the patients page resolves the actor before reading the address instead of after, since the address read itself now needs it (still ahead of the store and every audited read, preserving the existing "redirect before anything is recorded" guarantee). Addresses PR #2705 review thread PRRT_kwDOSh5Fis6f00qC. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L8jaqEKUUgnjPrQgR18igS --- .../caring-contacts/patients/search/route.ts | 13 +- src/app/caring-contacts/patients/page.tsx | 22 ++- .../caring-contacts/caseload-search-token.ts | 82 +++++++++-- .../patients-directory-address.ts | 12 +- ...g-contacts-patients-directory.dom.test.tsx | 24 ++-- tests/caring-contacts-search-privacy.test.ts | 133 ++++++++++++++---- 6 files changed, 234 insertions(+), 52 deletions(-) diff --git a/src/app/api/caring-contacts/patients/search/route.ts b/src/app/api/caring-contacts/patients/search/route.ts index c96d48ad1a..aafc6bd3dd 100644 --- a/src/app/api/caring-contacts/patients/search/route.ts +++ b/src/app/api/caring-contacts/patients/search/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { isCaringContactsDemoEnabled, resolveDemoActor } from "@/lib/caring-contacts-server/session"; import { CARING_CONTACTS_ROUTES } from "@/lib/caring-contacts-routes"; import { createSearchFilterToken } from "@/lib/caring-contacts/caseload-search-token"; import { @@ -16,6 +17,7 @@ import { PATIENTS_DIRECTORY_STATE_ORDER, } from "@/lib/caring-contacts/patients-directory-filter"; import { CARING_CONTACTS_STATE_PARAM } from "@/lib/caring-contacts/workspace-address"; +import { jsonError, PublicApiError } from "@/lib/http"; import { parseJsonBody } from "@/lib/validation/body"; export const runtime = "nodejs"; @@ -31,6 +33,11 @@ const searchRequestSchema = z .strict(); export async function POST(request: NextRequest): Promise { + // Same production lock every other Caring Contacts demo route uses (see + // `session/route.ts`'s `demoUnavailableResponse`): the actor this route mints a token for comes + // from the demo role cookie, which does not exist as an authorization boundary outside the demo. + if (!isCaringContactsDemoEnabled()) return jsonError(new PublicApiError("Not found.", 404), 404, { log: false }); + let body: z.infer; try { body = await parseJsonBody(request, searchRequestSchema); @@ -38,8 +45,12 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ error: "invalid-request-payload" }, { status: 400 }); } + const actor = await resolveDemoActor(); const query = body.query.trim(); - const filterToken = createSearchFilterToken(query); + // Bound to the searching actor (#HDCF2B follow-up): a token minted here can only be redeemed by + // this same actor later holding `viewPatientRecord` -- see `caseload-search-token.ts`'s module + // note for why the token itself is not the authorization boundary. + const filterToken = createSearchFilterToken(query, actor); const searchParams = new URLSearchParams(); if (body.state && body.state !== "all") { diff --git a/src/app/caring-contacts/patients/page.tsx b/src/app/caring-contacts/patients/page.tsx index e92ff5d56d..e47184be03 100644 --- a/src/app/caring-contacts/patients/page.tsx +++ b/src/app/caring-contacts/patients/page.tsx @@ -101,11 +101,18 @@ const CaringContactsShell = dynamic(() => * search term was not applied without ever echoing it. * * THE REDIRECT IS THE FIRST THING THIS PAGE DOES, and that placement is the guarantee rather than a - * tidiness preference: it happens before `resolveDemoActor`, before the store is opened and before - * every `auditedRead` below, so a dropped value cannot reach an access-trail record, an error - * message or a thrown `Error` on its way through. `redirect()` in a Server Component is a 307 that - * REPLACES the history entry (Next 16 `redirect` reference), so the bookmarked address carrying the - * name is not left behind as an entry of its own. + * tidiness preference: it happens before the store is opened and before every `auditedRead` below, + * so a dropped value cannot reach an access-trail record, an error message or a thrown `Error` on + * its way through. `redirect()` in a Server Component is a 307 that REPLACES the history entry + * (Next 16 `redirect` reference), so the bookmarked address carrying the name is not left behind as + * an entry of its own. + * + * `resolveDemoActor()` now runs BEFORE the address is read, not after -- `readPatientsDirectoryAddress` + * needs the actor to decide whether a `filterToken` in the address may be redeemed for THIS viewer at + * all (see that function's module note). That is still safe to do ahead of the redirect: it is a + * cookie read, not a store read, and records nothing to an access trail, so the property above -- + * nothing crosses into the store or an audited read before a dropped value has been caught -- holds + * exactly as before. */ export default async function CaringContactsPatientsPage({ searchParams, @@ -114,8 +121,10 @@ export default async function CaringContactsPatientsPage({ }) { if (!isCaringContactsDemoEnabled()) notFound(); + const actor = await resolveDemoActor(); + // Before anything is read, audited or thrown. See "IGNORING A BOOKMARKED ?q= WAS NOT ENOUGH". - const address = readPatientsDirectoryAddress(await searchParams); + const address = readPatientsDirectoryAddress(await searchParams, actor); if (address.droppedUnrecognisedParams) { redirect( address.canonicalQuery === "" @@ -125,7 +134,6 @@ export default async function CaringContactsPatientsPage({ } const filter = address.filter; - const actor = await resolveDemoActor(); const store = await caringContactsStore(); // "service" names the one service-wide record, matching the object id the API route records diff --git a/src/lib/caring-contacts/caseload-search-token.ts b/src/lib/caring-contacts/caseload-search-token.ts index 46e57cf047..f4dfcf6d90 100644 --- a/src/lib/caring-contacts/caseload-search-token.ts +++ b/src/lib/caring-contacts/caseload-search-token.ts @@ -8,17 +8,47 @@ // // Complies with Ruling [111]: "a query string is logged by every proxy between here and the browser. // Nothing about a patient may travel here." +// +// THE TOKEN ITSELF IS NOT THE AUTHORIZATION BOUNDARY. An opaque token in a URL still travels +// everywhere a plaintext query would have -- browser history, a Referer header, a proxy access +// log -- so anyone who later obtains the URL from one of those places can replay it. Removing the +// PATIENT'S NAME from the log is the property this module buys; it does not, on its own, stop a +// replayed token from resolving. That second property comes from binding the token to the actor +// who minted it (their id and team, from the same demo-role identity every other Caring Contacts +// read is checked against) and re-checking, on every resolution, that the CURRENT actor is that +// same actor AND currently holds `viewPatientRecord` (`READ_ACTIONS.patientName`). A token replayed +// by anyone else -- a different role, a stale/removed grant, or no session at all -- resolves to +// null, exactly like an expired one, so the page treats it as a dropped search rather than as a +// name to render. import { randomBytes } from "node:crypto"; +import type { CaringContactActor } from "./permissions"; +import { canPerformCaringContactAction, isSystemActor } from "./permissions"; +import { READ_ACTIONS } from "./repository"; + const TOKEN_PREFIX = "sft_"; export const DEFAULT_SEARCH_TOKEN_TTL_MS = 15 * 60 * 1000; // 15 minutes type TokenEntry = { query: string; expiresAt: number; + /** The actor who minted this token, so a resolution can require the SAME actor to redeem it. */ + ownerActorId: string; + ownerTeamId: string; }; +/** + * True when `actor` is the one this token entry was minted for. A system actor never mints or + * redeems a search token -- caseload search is a human, interactive action -- so it is refused + * here rather than compared by id, which would otherwise let a dispatcher-shaped actor id collide + * with a human one. + */ +function isOwningActor(entry: TokenEntry, actor: CaringContactActor): boolean { + if (isSystemActor(actor)) return false; + return actor.id === entry.ownerActorId && actor.teamId === entry.ownerTeamId; +} + const tokenStore = new Map(); /** @@ -33,12 +63,20 @@ function pruneExpiredTokens(now: number = Date.now()): void { } /** - * Creates an obfuscated session filter token for a search query. + * Creates an obfuscated session filter token for a search query, bound to the actor who searched. * Produces an opaque, random token that contains ZERO patient identifiers or PHI. + * + * `actor` is stored alongside the query (id and team only, never a role list or anything else + * identifying) so `resolveSearchFilterToken` can later refuse to redeem the token for anyone else. */ -export function createSearchFilterToken(query: string, options?: { ttlMs?: number; now?: number }): string { +export function createSearchFilterToken( + query: string, + actor: CaringContactActor, + options?: { ttlMs?: number; now?: number }, +): string { const trimmed = query.trim(); if (trimmed === "") return ""; + if (isSystemActor(actor)) return ""; const now = options?.now ?? Date.now(); pruneExpiredTokens(now); @@ -50,16 +88,29 @@ export function createSearchFilterToken(query: string, options?: { ttlMs?: numbe tokenStore.set(token, { query: trimmed, expiresAt: now + ttlMs, + ownerActorId: actor.id, + ownerTeamId: actor.teamId, }); return token; } /** - * Resolves an obfuscated session filter token back into the search string. - * Returns null if the token is invalid, expired, malformed, or empty. + * Resolves an obfuscated session filter token back into the search string -- but ONLY for the + * actor it was minted for, and only while that actor currently holds `viewPatientRecord` + * (`READ_ACTIONS.patientName`, the same capability every other patient-name read in this + * workspace is checked against). + * + * Returns null if the token is invalid, expired, malformed, empty, minted for a different actor, + * or the resolving actor no longer holds the name-view capability. Every one of those cases is + * deliberately indistinguishable from the others to the caller: a stale grant and a replayed URL + * both come back as "no search to apply", never as a reason that could itself leak something. */ -export function resolveSearchFilterToken(token: string | null | undefined, options?: { now?: number }): string | null { +export function resolveSearchFilterToken( + token: string | null | undefined, + actor: CaringContactActor, + options?: { now?: number }, +): string | null { if (!token || !token.startsWith(TOKEN_PREFIX)) return null; const now = options?.now ?? Date.now(); @@ -74,16 +125,31 @@ export function resolveSearchFilterToken(token: string | null | undefined, optio return null; } + if (!isOwningActor(entry, actor)) { + return null; + } + + const mayViewPatientNames = canPerformCaringContactAction(actor, READ_ACTIONS.patientName, { + teamId: actor.teamId, + }).allowed; + if (!mayViewPatientNames) { + return null; + } + return entry.query; } /** * Returns true if a string matches the format of an obfuscated search filter token - * and resolves to a valid active search query. + * and resolves to a valid active search query for `actor`. */ -export function isSearchFilterToken(value: unknown, options?: { now?: number }): boolean { +export function isSearchFilterToken( + value: unknown, + actor: CaringContactActor, + options?: { now?: number }, +): boolean { if (typeof value !== "string" || !value.startsWith(TOKEN_PREFIX)) return false; - return resolveSearchFilterToken(value, options) !== null; + return resolveSearchFilterToken(value, actor, options) !== null; } /** diff --git a/src/lib/caring-contacts/patients-directory-address.ts b/src/lib/caring-contacts/patients-directory-address.ts index db52abe3a7..07bc627cf2 100644 --- a/src/lib/caring-contacts/patients-directory-address.ts +++ b/src/lib/caring-contacts/patients-directory-address.ts @@ -21,6 +21,7 @@ import { PATIENTS_DIRECTORY_SEARCH_NOT_APPLIED_PARAM, type PatientsDirectoryFilter, } from "./patients-directory-filter"; +import type { CaringContactActor } from "./permissions"; import { CARING_CONTACTS_STATE_PARAM } from "./workspace-address"; /** What the address says, and what it should be rewritten to. Never carries a dropped VALUE. */ @@ -54,14 +55,23 @@ export type PatientsDirectoryAddress = { * each history entry it pushes -- so an ignored name was re-written into a fresh history entry * every time a coordinator opened an overlay. Not reading a value is not the same as removing it, * and on this page not reading it actively multiplied it. + * + * `actor` is who is looking NOW, not who searched. A `filterToken` is still an opaque id in a URL, + * and a URL is exactly what a browser history entry, a Referer header, or a proxy access log + * retains -- so this function must not treat "the token resolves" as "the current viewer may see + * the query it names". `resolveSearchFilterToken` checks that `actor` is the one the token was + * minted for AND currently holds `viewPatientRecord`; anyone else redeeming a replayed token gets + * exactly the same outcome as an expired one -- `searchQuery` stays absent and the address is + * rewritten to say a saved search term was not applied, never why. */ export function readPatientsDirectoryAddress( searchParams: Readonly>, + actor: CaringContactActor, ): PatientsDirectoryAddress { const filter = parsePatientsDirectoryFilter(searchParams); const rawFilterToken = searchParams[PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]; const filterToken = typeof rawFilterToken === "string" ? rawFilterToken : undefined; - const searchQuery = filterToken ? (resolveSearchFilterToken(filterToken) ?? undefined) : undefined; + const searchQuery = filterToken ? (resolveSearchFilterToken(filterToken, actor) ?? undefined) : undefined; const invalidToken = Boolean(filterToken && !searchQuery); const droppedUnrecognisedParams = diff --git a/tests/caring-contacts-patients-directory.dom.test.tsx b/tests/caring-contacts-patients-directory.dom.test.tsx index 14729a8908..ca4ff0a40d 100644 --- a/tests/caring-contacts-patients-directory.dom.test.tsx +++ b/tests/caring-contacts-patients-directory.dom.test.tsx @@ -28,6 +28,7 @@ import { describe, expect, it } from "vitest"; import { patientsDirectoryHref } from "@/components/caring-contacts/workspace/patients-directory-client"; import { WORKSPACE_OVERLAY_PARAM } from "@/components/caring-contacts/workspace/overlays/workspace-overlays"; import { PatientsDirectory } from "@/components/caring-contacts/workspace/patients-directory"; +import { demoActorForRole } from "@/lib/caring-contacts-server/session"; import { CARING_CONTACTS_ROUTES, patientRoute } from "@/lib/caring-contacts-routes"; import { contactId, pathwayVersionId, patientId, planId, referralId, teamId } from "@/lib/caring-contacts/ids"; import type { PlanState } from "@/lib/caring-contacts/model"; @@ -874,9 +875,12 @@ describe("Patients directory - a role that may not see names is told once, not p describe("Patients directory - a bookmarked search term is stripped from the address, not just unread", () => { const NAME = "Jordan Nguyen"; + // No filterToken appears in any address these tests build, so the actor never gates a resolved + // query here -- it exists only to satisfy `readPatientsDirectoryAddress`'s required parameter. + const ACTOR = demoActorForRole("coordinator"); it("reports an unrecognised parameter as a BOOLEAN, and rebuilds a query that cannot carry it", () => { - const address = readPatientsDirectoryAddress({ state: "active", q: NAME }); + const address = readPatientsDirectoryAddress({ state: "active", q: NAME }, ACTOR); expect(address.droppedUnrecognisedParams).toBe(true); expect(address.searchNotApplied).toBe(true); @@ -892,21 +896,23 @@ describe("Patients directory - a bookmarked search term is stripped from the add it("triggers on any unrecognised name, because a bookmark need not say `q`", () => { for (const key of ["q", "name", "search", "patient", "filter"]) { - expect(readPatientsDirectoryAddress({ [key]: NAME }).droppedUnrecognisedParams, key).toBe(true); + expect(readPatientsDirectoryAddress({ [key]: NAME }, ACTOR).droppedUnrecognisedParams, key).toBe(true); } // ...and not on the ones this route does understand, or the rewrite would fire forever. - expect(readPatientsDirectoryAddress({}).droppedUnrecognisedParams).toBe(false); - expect(readPatientsDirectoryAddress({ state: "active" }).droppedUnrecognisedParams).toBe(false); - expect(readPatientsDirectoryAddress({ searchNotApplied: "1" }).droppedUnrecognisedParams).toBe(false); - expect(readPatientsDirectoryAddress({ overlay: "consent-and-withdrawal" }).droppedUnrecognisedParams).toBe(false); + expect(readPatientsDirectoryAddress({}, ACTOR).droppedUnrecognisedParams).toBe(false); + expect(readPatientsDirectoryAddress({ state: "active" }, ACTOR).droppedUnrecognisedParams).toBe(false); + expect(readPatientsDirectoryAddress({ searchNotApplied: "1" }, ACTOR).droppedUnrecognisedParams).toBe(false); + expect( + readPatientsDirectoryAddress({ overlay: "consent-and-withdrawal" }, ACTOR).droppedUnrecognisedParams, + ).toBe(false); }); it("produces a rewrite target that is itself clean, so the redirect cannot loop", () => { - const address = readPatientsDirectoryAddress({ state: "paused", overlay: "consent-and-withdrawal", q: NAME }); + const address = readPatientsDirectoryAddress({ state: "paused", overlay: "consent-and-withdrawal", q: NAME }, ACTOR); const rewritten = Object.fromEntries(new URLSearchParams(address.canonicalQuery)); // Feed the target back through the same reader: it must ask for no further rewrite. - expect(readPatientsDirectoryAddress(rewritten).droppedUnrecognisedParams).toBe(false); + expect(readPatientsDirectoryAddress(rewritten, ACTOR).droppedUnrecognisedParams).toBe(false); // ...while still carrying everything that was allowed to survive. expect(rewritten.state).toBe("paused"); expect(rewritten.overlay).toBe("consent-and-withdrawal"); @@ -920,7 +926,7 @@ describe("Patients directory - a bookmarked search term is stripped from the add // cannot fail, and an assertion that cannot fail is worse than none. What can still go wrong is // this route dropping the parameter the writer uses, which is what is asserted instead: a // deep-linked overlay must survive the caseload's own address rewrite. - const address = readPatientsDirectoryAddress({ [WORKSPACE_OVERLAY_PARAM]: "consent-and-withdrawal", q: "x" }); + const address = readPatientsDirectoryAddress({ [WORKSPACE_OVERLAY_PARAM]: "consent-and-withdrawal", q: "x" }, ACTOR); expect(address.droppedUnrecognisedParams).toBe(true); expect(new URLSearchParams(address.canonicalQuery).get(WORKSPACE_OVERLAY_PARAM)).toBe("consent-and-withdrawal"); }); diff --git a/tests/caring-contacts-search-privacy.test.ts b/tests/caring-contacts-search-privacy.test.ts index 45a5106d9b..f90bd11f93 100644 --- a/tests/caring-contacts-search-privacy.test.ts +++ b/tests/caring-contacts-search-privacy.test.ts @@ -1,7 +1,8 @@ // tests/caring-contacts-search-privacy.test.ts -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +import { demoActorForRole } from "@/lib/caring-contacts-server/session"; import { clearSearchFilterTokenStore, createSearchFilterToken, @@ -15,6 +16,30 @@ import { } from "@/lib/caring-contacts/patients-directory-filter"; import { POST as searchRoutePost } from "@/app/api/caring-contacts/patients/search/route"; +// The route now resolves the caller's actor from the demo role cookie (via `resolveDemoActor`) to +// bind the minted token to it -- same mock shape `caring-contacts-session.test.ts` uses for the +// same reason: `cookies()` needs the Next request-scoped store this raw handler call has none of. +// An unset cookie falls back to the coordinator, matching `coordinator` below. +vi.mock("next/headers", () => ({ + cookies: vi.fn(async () => ({ get: () => undefined, set: () => undefined })), +})); + +// Every DEMO_ROLE holds `viewReferral` and `viewPatientRecord` together (see `permissions.ts`'s +// grant tables), so a coordinator is an ordinary actor that MAY redeem its own token -- exactly +// the case these tests other than the ownership ones want to exercise without also asserting the +// permission check. +const coordinator = demoActorForRole("coordinator"); +const otherCoordinator = { ...coordinator, id: demoActorForRole("teamLead").id }; +// Every DEMO_ROLE currently grants `viewPatientRecord` together with `viewReferral` (see +// `patients-directory.tsx`'s module note: "NO ROLE REACHES THE NOTICE TODAY" -- the grant tables +// never separate the two), so there is no real role today that mints a token and later lacks the +// name-view capability. `revokedCoordinator` stands in for "the same session, but the grant that +// was true when it searched is no longer true" -- same id and team, no roles at all -- so the live +// permission re-check inside `resolveSearchFilterToken` is exercised even though the specific +// `viewPatientRecord` grant it happens to be checking cannot itself be un-reachable through any +// role in the table today, same principle as that module's own "unreachable branch, pinned anyway". +const revokedCoordinator = { ...coordinator, roles: [] }; + describe("Task 1 #HDCF2B: Patient privacy in caseload search (no PHI in URL / access logs)", () => { beforeEach(() => { clearSearchFilterTokenStore(); @@ -22,30 +47,30 @@ describe("Task 1 #HDCF2B: Patient privacy in caseload search (no PHI in URL / ac it("generates an opaque session filter token that does NOT expose raw patient names or base64 PHI", () => { const rawName = "Jordan Nguyen"; - const token = createSearchFilterToken(rawName); + const token = createSearchFilterToken(rawName, coordinator); expect(token).toMatch(/^sft_[a-f0-9]{32}$/); // Raw patient name must NOT appear in the token string in plaintext or trivial encoding expect(token).not.toContain("Jordan"); expect(token).not.toContain("Nguyen"); expect(token).not.toContain("jordan"); - expect(isSearchFilterToken(token)).toBe(true); + expect(isSearchFilterToken(token, coordinator)).toBe(true); // Decoding the token string as base64 / utf8 never yields patient name const raw = token.slice(4); expect(Buffer.from(raw, "base64url").toString("utf8")).not.toContain("Jordan"); - // Resolves back to the query via server-side session store - const resolved = resolveSearchFilterToken(token); + // Resolves back to the query via server-side session store, for the actor who minted it + const resolved = resolveSearchFilterToken(token, coordinator); expect(resolved).toBe(rawName); }); it("handles empty or whitespace query cleanly", () => { - expect(createSearchFilterToken("")).toBe(""); - expect(createSearchFilterToken(" ")).toBe(""); - expect(resolveSearchFilterToken("")).toBeNull(); - expect(resolveSearchFilterToken(null)).toBeNull(); - expect(resolveSearchFilterToken("sft_invalid-garbage")).toBeNull(); + expect(createSearchFilterToken("", coordinator)).toBe(""); + expect(createSearchFilterToken(" ", coordinator)).toBe(""); + expect(resolveSearchFilterToken("", coordinator)).toBeNull(); + expect(resolveSearchFilterToken(null, coordinator)).toBeNull(); + expect(resolveSearchFilterToken("sft_invalid-garbage", coordinator)).toBeNull(); }); it("enforces TTL expiration on search tokens", () => { @@ -53,21 +78,52 @@ describe("Task 1 #HDCF2B: Patient privacy in caseload search (no PHI in URL / ac const baseTime = 1700000000000; const ttlMs = 60 * 1000; // 1 minute - const token = createSearchFilterToken(rawName, { ttlMs, now: baseTime }); - expect(resolveSearchFilterToken(token, { now: baseTime + 30 * 1000 })).toBe(rawName); + const token = createSearchFilterToken(rawName, coordinator, { ttlMs, now: baseTime }); + expect(resolveSearchFilterToken(token, coordinator, { now: baseTime + 30 * 1000 })).toBe(rawName); // After TTL, token must expire and return null - expect(resolveSearchFilterToken(token, { now: baseTime + 65 * 1000 })).toBeNull(); + expect(resolveSearchFilterToken(token, coordinator, { now: baseTime + 65 * 1000 })).toBeNull(); + }); + + it("refuses to resolve a token for anyone other than the actor who minted it", () => { + // The scenario the finding names directly: a token obtained from browser history, a Referer + // header, or an access log -- by someone who is not the searching clinician -- must not + // resolve, even though the token itself is well-formed and unexpired. + const token = createSearchFilterToken("Jordan Nguyen", coordinator); + + expect(resolveSearchFilterToken(token, otherCoordinator)).toBeNull(); + expect(isSearchFilterToken(token, otherCoordinator)).toBe(false); + + // The owning actor can still redeem it. + expect(resolveSearchFilterToken(token, coordinator)).toBe("Jordan Nguyen"); + }); + + it("refuses to resolve a token for the owning actor once they no longer hold viewPatientRecord", () => { + // Same actor id and team as the one the token was minted for, but no roles at all -- the live + // permission check must refuse the redemption even though the ownership check alone would pass. + const token = createSearchFilterToken("Jordan Nguyen", coordinator); + expect(resolveSearchFilterToken(token, revokedCoordinator)).toBeNull(); + }); + + it("refuses to mint or resolve a token for a system actor", () => { + const dispatcher = { id: coordinator.id, teamId: coordinator.teamId, systemRole: "contactDispatcher" as const }; + expect(createSearchFilterToken("Jordan Nguyen", dispatcher)).toBe(""); + + const token = createSearchFilterToken("Jordan Nguyen", coordinator); + expect(resolveSearchFilterToken(token, dispatcher)).toBeNull(); }); it("recognises valid filterToken in address without triggering dropped-parameter redirect", () => { expect(PATIENTS_DIRECTORY_RECOGNISED_PARAMS).toContain(PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM); - const token = createSearchFilterToken("Jordan Nguyen"); - const address = readPatientsDirectoryAddress({ - state: "active", - [PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]: token, - }); + const token = createSearchFilterToken("Jordan Nguyen", coordinator); + const address = readPatientsDirectoryAddress( + { + state: "active", + [PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]: token, + }, + coordinator, + ); // Valid filterToken is recognised, so droppedUnrecognisedParams must be false expect(address.droppedUnrecognisedParams).toBe(false); @@ -79,12 +135,34 @@ describe("Task 1 #HDCF2B: Patient privacy in caseload search (no PHI in URL / ac expect(address.canonicalQuery).not.toContain("Nguyen"); }); + it("treats a token minted for a different actor the same as an expired one -- no query, no name", () => { + const token = createSearchFilterToken("Jordan Nguyen", coordinator); + const address = readPatientsDirectoryAddress( + { + state: "active", + [PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]: token, + }, + otherCoordinator, + ); + + expect(address.droppedUnrecognisedParams).toBe(true); + expect(address.searchNotApplied).toBe(true); + expect(address.searchQuery).toBeUndefined(); + expect(address.canonicalQuery).not.toContain("filterToken"); + expect(address.canonicalQuery).not.toContain("Jordan"); + expect(address.canonicalQuery).not.toContain("Nguyen"); + expect(address.canonicalQuery).toBe("state=active&searchNotApplied=1"); + }); + it("drops expired or corrupted filterToken and sets searchNotApplied to clean the URL", () => { const corruptedToken = "sft_nonexistent_or_expired_12345"; - const address = readPatientsDirectoryAddress({ - state: "active", - [PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]: corruptedToken, - }); + const address = readPatientsDirectoryAddress( + { + state: "active", + [PATIENTS_DIRECTORY_FILTER_TOKEN_PARAM]: corruptedToken, + }, + coordinator, + ); // Expired/corrupted token must be dropped from canonical query expect(address.droppedUnrecognisedParams).toBe(true); @@ -103,7 +181,7 @@ describe("Task 1 #HDCF2B: Patient privacy in caseload search (no PHI in URL / ac ]; for (const params of rawParams) { - const address = readPatientsDirectoryAddress(params); + const address = readPatientsDirectoryAddress(params, coordinator); expect(address.droppedUnrecognisedParams).toBe(true); expect(address.searchNotApplied).toBe(true); // Canonical query must be clean of the unrecognised parameter @@ -113,7 +191,7 @@ describe("Task 1 #HDCF2B: Patient privacy in caseload search (no PHI in URL / ac } }); - it("POST /api/caring-contacts/patients/search receives body payload and returns filterToken", async () => { + it("POST /api/caring-contacts/patients/search receives body payload and returns filterToken bound to the caller", async () => { const request = new NextRequest("http://localhost/api/caring-contacts/patients/search", { method: "POST", headers: { "content-type": "application/json" }, @@ -135,7 +213,10 @@ describe("Task 1 #HDCF2B: Patient privacy in caseload search (no PHI in URL / ac expect(json.destination).not.toContain("Sarah"); expect(json.destination).not.toContain("Connor"); - // Token returned from endpoint resolves to the searched patient - expect(resolveSearchFilterToken(json.filterToken)).toBe("Sarah Connor"); + // The route resolves its own actor from the (mocked, unset -> coordinator) demo role cookie + // and that actor can redeem the token it just minted; a DIFFERENT actor cannot, because the + // route now binds the token to the actor who searched rather than to nobody in particular. + expect(resolveSearchFilterToken(json.filterToken, coordinator)).toBe("Sarah Connor"); + expect(resolveSearchFilterToken(json.filterToken, otherCoordinator)).toBeNull(); }); }); From 2f0d461e442f6612afc90c2955f0751334a0e91b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:19:08 +0000 Subject: [PATCH 04/15] fix(caring-contacts): wire the notification retry ladder into the twelve-month simulation DEFAULT_CONTACT_RETRY_POLICY (three attempts, 45 minutes apart) and the new notification retry backoff ladder in retry-queue.ts (1m/5m/15m/1h/6h, dead-letter after 5) were two separate, un-reconciled policies for the same clinical action. driveTwelveMonthSimulation -- the only non-test consumer of a retry policy in this codebase -- kept using the old flat-interval policy, so the advertised ladder never governed any real (simulated) contact retry. - retry-queue.ts: calculateRetryDelayMs/calculateNextRetryTime now take an optional ladder parameter (defaulting to RETRY_BACKOFF_LADDER_MS), so a different governed caller can share the same function instead of a parallel copy of the arithmetic. - service-rules.ts: ContactRetryPolicy now carries maxAttempts plus a backoffMs ladder (defaulting to retry-queue's own MAX_RETRY_ATTEMPTS / RETRY_BACKOFF_LADDER_MS) in place of a flat retryIntervalMinutes. - simulation.ts: driveTwelveMonthSimulation computes each attempt's offset from sendAt via calculateRetryDelayMs against the policy's ladder (attempt 1 carries the ladder's own first-attempt delay, matching NotificationRetryQueue's own enqueue-to-first-attempt behaviour), instead of a flat (attempt-1)*interval. isWithinApprovedSendWindow still refuses any attempt that would land outside the approved send hours or roll into the next AWST day -- that rule is unchanged and senior to the retry cadence, exactly as before. - tests/caring-contacts-simulation.test.ts updated for the new default (5 attempts, the real ladder offsets) and the new ContactRetryPolicy override shape, including a corrected window-boundary scenario (the ladder's much shorter early offsets mean the fourth attempt, not the third, is now the one that lands outside the window). Addresses PR #2705 review thread PRRT_kwDOSh5Fis6f00qM. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L8jaqEKUUgnjPrQgR18igS --- src/lib/caring-contacts/retry-queue.ts | 30 ++++++++---- src/lib/caring-contacts/service-rules.ts | 49 +++++++++++++------ src/lib/caring-contacts/simulation.ts | 34 ++++++++++++-- tests/caring-contacts-simulation.test.ts | 60 +++++++++++++++++------- 4 files changed, 129 insertions(+), 44 deletions(-) diff --git a/src/lib/caring-contacts/retry-queue.ts b/src/lib/caring-contacts/retry-queue.ts index 6b7b1f717d..933ecef24b 100644 --- a/src/lib/caring-contacts/retry-queue.ts +++ b/src/lib/caring-contacts/retry-queue.ts @@ -56,24 +56,38 @@ export type EnqueueRetryParams = { * Calculates backoff delay in milliseconds for a given retry attempt. * * Handles 0-retry queue records gracefully by assigning the first backoff step (1m). - * Returns `null` when max retries (5) have been exhausted. + * Returns `null` when the ladder's attempts (5, by default) have been exhausted. + * + * `ladder` defaults to `RETRY_BACKOFF_LADDER_MS` but is a parameter, not a hard-coded read of it, + * so a caller governed by a DIFFERENT backoff sequence -- `driveTwelveMonthSimulation`'s contact + * retry policy, whose ladder length also sets its own effective cap -- goes through this exact + * function rather than a second copy of the same arithmetic. See `service-rules.ts`'s + * `ContactRetryPolicy` for that caller. */ -export function calculateRetryDelayMs(attemptCount: number): number | null { +export function calculateRetryDelayMs( + attemptCount: number, + ladder: readonly number[] = RETRY_BACKOFF_LADDER_MS, +): number | null { if (!Number.isFinite(attemptCount) || attemptCount < 0) { - return RETRY_BACKOFF_LADDER_MS[0]; + return ladder[0] ?? null; } const index = Math.floor(attemptCount); - if (index >= MAX_RETRY_ATTEMPTS) return null; - return RETRY_BACKOFF_LADDER_MS[index]; + if (index >= ladder.length) return null; + return ladder[index]; } /** * Calculates the next retry timestamp for a given attempt count. * - * Prevents negative epoch or invalid date calculations. + * Prevents negative epoch or invalid date calculations. `ladder` is threaded through to + * `calculateRetryDelayMs` unchanged -- see that function's note on why it is a parameter. */ -export function calculateNextRetryTime(attemptCount: number, baseDate: Date = new Date()): Date | null { - const delayMs = calculateRetryDelayMs(attemptCount); +export function calculateNextRetryTime( + attemptCount: number, + baseDate: Date = new Date(), + ladder: readonly number[] = RETRY_BACKOFF_LADDER_MS, +): Date | null { + const delayMs = calculateRetryDelayMs(attemptCount, ladder); if (delayMs === null) return null; const baseTime = baseDate instanceof Date && Number.isFinite(baseDate.getTime()) ? Math.max(0, baseDate.getTime()) : Date.now(); diff --git a/src/lib/caring-contacts/service-rules.ts b/src/lib/caring-contacts/service-rules.ts index 15484b2381..5009c0f72e 100644 --- a/src/lib/caring-contacts/service-rules.ts +++ b/src/lib/caring-contacts/service-rules.ts @@ -12,33 +12,54 @@ // // Replace the values here when the service owner changes the policy; do not restate them at a // call site. +// +// #8K9W2B follow-up: the governed policy used to be its own flat interval (45 minutes, three +// attempts), invented for this file alone and never reconciled with the notification retry +// backoff ladder `./retry-queue` separately introduced. Two policies for one clinical action -- +// "try to deliver this caring contact again" -- is exactly the drift Ruling 46 exists to prevent, +// so this is now the SAME ladder, imported rather than restated: `driveTwelveMonthSimulation` +// (`./simulation`, the one non-test consumer of this policy) computes each attempt's offset with +// `calculateRetryDelayMs` from `./retry-queue`, the identical function `NotificationRetryQueue` +// uses, so a change to the ladder changes both call sites from one edit. + +import { MAX_RETRY_ATTEMPTS, RETRY_BACKOFF_LADDER_MS } from "./retry-queue"; /** * How many times one caring contact may be attempted, and how far apart. * - * `maxAttempts` counts the FIRST attempt, so 3 means one send and two retries. + * `maxAttempts` counts the FIRST attempt, so 5 means one send and four retries -- capped + * independently of `backoffMs.length` so a caller can shorten the run (fewer attempts) without + * inventing a shorter ladder. + * + * `backoffMs[i]` is the delay, in milliseconds, before attempt `i + 1` — the SAME indexing + * `calculateRetryDelayMs` in `./retry-queue` uses, because attempt 1 is not sent the instant the + * contact's `sendAt` arrives; it goes through the same 1-minute dispatch delay every other attempt + * does. `backoffMs[0]` is therefore the gap between `sendAt` and attempt 1, `backoffMs[1]` between + * attempt 1 and attempt 2, and so on. * * Retries never extend the send window. Whether an attempt is still allowed is decided by * `isWithinApprovedSendWindow` in ./schedule and by the contact's own AWST calendar day — a retry - * that would roll past 18:00, or into the next day, does not happen. This policy therefore sets - * how often and how many, never how late. + * that would roll past 18:00, or into the next day, does not happen, however many backoff steps + * remain. This policy therefore sets how often and how many are OFFERED, never how late one may + * land; the window is a separate, and senior, rule. */ export type ContactRetryPolicy = { - /** Total attempts including the first. */ + /** Total attempts including the first, capped independently of `backoffMs.length`. */ maxAttempts: number; - /** Minutes between attempts. */ - retryIntervalMinutes: number; + /** Delay before each attempt, indexed from the first — see the type's own note above. */ + backoffMs: readonly number[]; }; /** - * Two retries, three attempts in total, 45 minutes apart. - * - * 45 minutes keeps all three attempts inside the approved window for every approved send hour: - * the latest is 17:00 AWST, and 17:00 + 2 × 45 minutes is 18:30 — outside it — so the third - * attempt of an early-evening contact is correctly refused rather than sent late. That is the - * intended interaction, not an oversight: the window wins over the retry count. + * The notification retry backoff ladder (1m, 5m, 15m, 1h, 6h) and its 5-attempt cap, both read + * from `./retry-queue` rather than restated: this IS that ladder, not a caring-contacts-flavoured + * copy of it. A caring contact whose fifth attempt still fails is refused exactly as the + * notification queue dead-letters a fifth failure -- `driveTwelveMonthSimulation` records it + * `missed` rather than `dead_letter` because a missed clinical contact and an undelivered + * notification are reported to different audiences, but the retry arithmetic behind both is now + * one function, not two. */ export const DEFAULT_CONTACT_RETRY_POLICY: ContactRetryPolicy = Object.freeze({ - maxAttempts: 3, - retryIntervalMinutes: 45, + maxAttempts: MAX_RETRY_ATTEMPTS, + backoffMs: RETRY_BACKOFF_LADDER_MS, }); diff --git a/src/lib/caring-contacts/simulation.ts b/src/lib/caring-contacts/simulation.ts index 1a776767c1..f095fe8793 100644 --- a/src/lib/caring-contacts/simulation.ts +++ b/src/lib/caring-contacts/simulation.ts @@ -34,6 +34,7 @@ import { PLAN_ASSURANCE_VALUES } from "./assurances"; import { idempotencyKey } from "./ids"; import type { PathwayVersionId, PatientId, PlanId, ReferralId } from "./ids"; import type { ProviderStatus, SendingPreference, TransitionResult } from "./model"; +import { calculateRetryDelayMs } from "./retry-queue"; import { DEFAULT_CONTACT_RETRY_POLICY, type ContactRetryPolicy } from "./service-rules"; import { createInMemoryRepository } from "./in-memory-repository"; import type { Actor, SystemActor } from "./permissions"; @@ -151,6 +152,30 @@ function reasonOf(result: TransitionResult): string { return result.ok ? "" : result.reason; } +/** + * The offset from `sendAt`, in milliseconds, at which attempt `k` (1-indexed) is made, for + * `k` = 1..`policy.maxAttempts` -- `offsets[k - 1]` is attempt `k`'s offset. Computed once per run + * rather than per contact, since the policy does not vary contact to contact. + * + * This is `calculateRetryDelayMs` from `./retry-queue` (see `ContactRetryPolicy`'s module note for + * why attempt 1 is not offset 0) accumulated: attempt `k`'s offset is the sum of the first `k` + * ladder steps. A policy shorter than its own `maxAttempts` claims -- `backoffMs` exhausted before + * `maxAttempts` attempts are scheduled -- stops offering offsets rather than inventing one, exactly + * as `calculateRetryDelayMs` returning `null` stops `NotificationRetryQueue` from rescheduling a + * dead-lettered item. + */ +function attemptOffsetsMs(policy: ContactRetryPolicy): number[] { + const offsets: number[] = []; + let cumulativeMs = 0; + for (let attemptIndex = 0; attemptIndex < policy.maxAttempts; attemptIndex += 1) { + const delay = calculateRetryDelayMs(attemptIndex, policy.backoffMs); + if (delay === null) break; + cumulativeMs += delay; + offsets.push(cumulativeMs); + } + return offsets; +} + /** * Drives one twelve-month episode and returns the report together with the store it ran against. * Throws only when the run could not be set up at all (the plan could not be created or activated); @@ -262,6 +287,9 @@ export async function driveTwelveMonthSimulation(input: SimulationInput): Promis } const schedule = (await store.listContacts(planId, { actor: input.coordinator })).sort(bySendAt); + // One ladder walk for the whole run -- see the function's own note on why offsets don't vary + // contact to contact. + const retryOffsetsMs = attemptOffsetsMs(retryPolicy); const dispatched: PlannedContact[] = []; const missed: PlannedContact[] = []; @@ -274,10 +302,8 @@ export async function driveTwelveMonthSimulation(input: SimulationInput): Promis let wentOut = false; - for (let attempt = 1; attempt <= retryPolicy.maxAttempts; attempt += 1) { - const at = new Date( - planned.sendAt.getTime() + (attempt - 1) * retryPolicy.retryIntervalMinutes * MILLISECONDS_PER_MINUTE, - ); + for (let attempt = 1; attempt <= retryOffsetsMs.length; attempt += 1) { + const at = new Date(planned.sendAt.getTime() + retryOffsetsMs[attempt - 1]); advanceTo(at); // Events land between retry attempts too, not only between contacts. A death recorded while diff --git a/tests/caring-contacts-simulation.test.ts b/tests/caring-contacts-simulation.test.ts index ba172463a7..c977373c88 100644 --- a/tests/caring-contacts-simulation.test.ts +++ b/tests/caring-contacts-simulation.test.ts @@ -231,10 +231,13 @@ describe("scenario 2: a transient failure retries inside the original window and for (const attempt of forMonthOne) { expect(awstCalendarDay(attempt.at)).toBe("2026-04-02"); } + // Attempt offsets from sendAt now follow the notification retry ladder in ./retry-queue + // (1m, 5m, 15m, 1h, 6h), accumulated: attempt 1 at +1m, attempt 2 at +1m+5m=+6m, attempt 3 at + // +6m+15m=+21m. See ContactRetryPolicy's module note in service-rules.ts. expect(forMonthOne.map((attempt) => attempt.at.toISOString())).toEqual([ - "2026-04-02T02:00:00.000Z", - "2026-04-02T02:45:00.000Z", - "2026-04-02T03:30:00.000Z", + "2026-04-02T02:01:00.000Z", + "2026-04-02T02:06:00.000Z", + "2026-04-02T02:21:00.000Z", ]); expect(run.report.dispatched).toHaveLength(10); @@ -249,13 +252,19 @@ describe("scenario 2: a transient failure retries inside the original window and }); it("gives up after the cap and records the contact as missed, having sent nothing", async () => { + // The governed default now caps at 5 attempts (./retry-queue's MAX_RETRY_ATTEMPTS), so all + // five must fail transiently to exercise giving up -- `scriptedTransport` defaults any + // unscripted attempt to "delivered", so a shorter script here would let attempt 4 succeed by + // accident rather than by the scenario asking it to. const run = await driveTwelveMonthSimulation( simulationInput({ - transport: scriptedTransport({ 3: ["transient", "transient", "transient"] }), + transport: scriptedTransport({ + 3: ["transient", "transient", "transient", "transient", "transient"], + }), }), ); - expect(run.attempts.filter((attempt) => attempt.sequence === 3)).toHaveLength(3); + expect(run.attempts.filter((attempt) => attempt.sequence === 3)).toHaveLength(5); expect(sequencesOf(run.report.missed)).toEqual([3]); expect(sequencesOf(run.report.dispatched)).toEqual([1, 2, 4, 5, 6, 7, 8, 9, 10]); expect(stateOf(run, 3)).toBe("missed"); @@ -272,8 +281,13 @@ describe("scenario 2: a transient failure retries inside the original window and // --------------------------------------------------------------------------- describe("the retry policy has a governed home", () => { - it("is two retries, three attempts in total, and is the value the driver uses when none is given", async () => { - expect(DEFAULT_CONTACT_RETRY_POLICY).toEqual({ maxAttempts: 3, retryIntervalMinutes: 45 }); + it("is the notification retry backoff ladder (#8K9W2B), and is the value the driver uses when none is given", async () => { + // The governed default is now ./retry-queue's own ladder and cap, not a caring-contacts-only + // number -- see ContactRetryPolicy's module note in service-rules.ts for why. + expect(DEFAULT_CONTACT_RETRY_POLICY).toEqual({ + maxAttempts: 5, + backoffMs: [60_000, 300_000, 900_000, 3_600_000, 21_600_000], + }); const run = await driveTwelveMonthSimulation( simulationInput({ transport: scriptedTransport({ 1: ["transient", "transient", "delivered"] }) }), @@ -281,7 +295,10 @@ describe("the retry policy has a governed home", () => { const forFirst = run.attempts.filter((attempt) => attempt.sequence === 1); expect(forFirst).toHaveLength(3); - expect(forFirst[1].at.getTime() - forFirst[0].at.getTime()).toBe(45 * 60_000); + // Attempt 2's offset minus attempt 1's is the SECOND ladder step (5m) -- attempt 1 already + // carries the first step (1m) as its own offset from sendAt, matching how + // NotificationRetryQueue schedules its own first attempt one minute after enqueue. + expect(forFirst[1].at.getTime() - forFirst[0].at.getTime()).toBe(300_000); expect(stateOf(run, 1)).toBe("delivered"); }); @@ -295,7 +312,7 @@ describe("the retry policy has a governed home", () => { expect(run.attempts.filter((attempt) => attempt.sequence === 1)).toHaveLength(1); expect(stateOf(run, 1)).toBe("missed"); - expect(DEFAULT_CONTACT_RETRY_POLICY.maxAttempts).toBe(3); + expect(DEFAULT_CONTACT_RETRY_POLICY.maxAttempts).toBe(5); }); }); @@ -305,21 +322,24 @@ describe("the retry policy has a governed home", () => { describe("scenario 3: retries that would leave the window are abandoned, never sent late", () => { it("stops at the window edge and marks the contact missed even though the next attempt would have succeeded", async () => { - // 17:00 AWST + 45 + 45 = 18:30, past the approved 18:00 boundary. + // 17:00 AWST + the ladder's first three offsets (1m, 6m, 21m cumulative) all land inside the + // window; the FOURTH attempt's offset (1h21m cumulative) does not: 17:00 + 1h21m = 18:21, + // past the approved 18:00 boundary. const run = await driveTwelveMonthSimulation( simulationInput({ plan: planInput({ sendingPreference: "earlyEvening" }), - // No retryPolicy: the governed default is 3 attempts 45 minutes apart, and the third - // landing outside the window is the interaction this scenario exists to prove. - transport: scriptedTransport({ 3: ["transient", "transient", "delivered"] }), + // No retryPolicy: the governed default is ./retry-queue's own ladder, and the fourth + // attempt landing outside the window is the interaction this scenario exists to prove. + transport: scriptedTransport({ 3: ["transient", "transient", "transient", "delivered"] }), }), ); const forMonthOne = run.attempts.filter((attempt) => attempt.sequence === 3); - expect(forMonthOne).toHaveLength(2); + expect(forMonthOne).toHaveLength(3); expect(forMonthOne.map((attempt) => attempt.at.toISOString())).toEqual([ - "2026-04-02T09:00:00.000Z", // 17:00 AWST - "2026-04-02T09:45:00.000Z", // 17:45 AWST + "2026-04-02T09:01:00.000Z", // 17:01 AWST (+1m) + "2026-04-02T09:06:00.000Z", // 17:06 AWST (+1m+5m) + "2026-04-02T09:21:00.000Z", // 17:21 AWST (+1m+5m+15m) ]); expect(sequencesOf(run.report.missed)).toEqual([3]); @@ -343,10 +363,12 @@ describe("scenario 3: retries that would leave the window are abandoned, never s it("never lets a retry roll into the next day's window", async () => { // A retry interval long enough to land inside 09:00-18:00 of the FOLLOWING day if the day were // not checked: 17:00 + 17h = 10:00 the next morning, which is inside the window by hour alone. + // The first offset is 0 (attempt 1 stays exactly at sendAt) so this override isolates the + // day-rollover interaction from the ladder's own first-attempt delay. const run = await driveTwelveMonthSimulation( simulationInput({ plan: planInput({ sendingPreference: "earlyEvening" }), - retryPolicy: { maxAttempts: 2, retryIntervalMinutes: 17 * 60 }, + retryPolicy: { maxAttempts: 2, backoffMs: [0, 17 * 60 * 60_000] }, transport: scriptedTransport({ 3: ["transient", "delivered"] }), }), ); @@ -547,9 +569,11 @@ describe("scenario 7: nothing is dispatched at or after a recorded death", () => }); it("does not send when the death lands between two retry attempts", async () => { + // Attempt 1 is at sendAt+1m (the ladder's first offset) and attempt 2 at sendAt+6m -- the + // death at +3m must land strictly between the two for this scenario to prove what it claims. const run = await driveTwelveMonthSimulation( simulationInput({ - events: [deathAt(minutesAfter(MONTH_3_AT, 20))], + events: [deathAt(minutesAfter(MONTH_3_AT, 3))], transport: scriptedTransport({ 5: ["transient", "delivered", "delivered"] }), }), ); From 73c932d0c760c89f94af15d7311c8c4c0bee0daa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:22:43 +0000 Subject: [PATCH 05/15] fix(caring-contacts): drop server-only marker package from the sealed domain CI's caring-contacts-domain-isolation test flagged the new patients-directory-address.ts for importing the "server" + "only" marker package: tests/caring-contacts-domain-isolation.test.ts holds every file under src/lib/caring-contacts/ to an allowlist of import specifiers (relative imports and bare node: builtins only), as proof the whole domain is self-contained and provider-free. That marker package is neither, and caseload-search-token.ts (which already imports node:crypto directly) never carried it either, so this file shouldn't be the first to. Removed the import; the module note now explains why. The boundary this file exists to enforce is structural, not marker-based: patients-directory-filter.ts (imported by the "use client" directory component) never imports this file or caseload-search-token.ts, so the client bundle cannot reach node:crypto regardless. Verified: tests/caring-contacts-domain-isolation.test.ts and the full caring-contacts test set from the prior commit still pass (132/132), typecheck clean, eslint clean, Prettier clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L8jaqEKUUgnjPrQgR18igS --- .../patients-directory-address.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib/caring-contacts/patients-directory-address.ts b/src/lib/caring-contacts/patients-directory-address.ts index 07bc627cf2..100b28a148 100644 --- a/src/lib/caring-contacts/patients-directory-address.ts +++ b/src/lib/caring-contacts/patients-directory-address.ts @@ -7,11 +7,17 @@ // This is split out of `patients-directory-filter.ts` deliberately: that module is imported by // `patients-directory-client.tsx`, a `"use client"` component, and a client bundle that reaches // `node:crypto` fails webpack outright (`UnhandledSchemeError: Reading from "node:crypto" is not -// handled by plugins`). `import "server-only"` below turns that failure mode into a clear build -// error at the actual import site if this file is ever reached from a client component, rather -// than the confusing "why is Node core code in my browser bundle" trace this split replaces. -import "server-only"; - +// handled by plugins`). +// +// This file deliberately does NOT depend on the "server" + "only" marker package used elsewhere +// in the repo, matching `caseload-search-token.ts` itself. `tests/caring-contacts-domain- +// isolation.test.ts` holds every file under this directory to an ALLOWLIST of import specifiers -- +// relative imports and bare `node:` builtins only -- as proof that the whole `caring-contacts` +// domain is self-contained and provider-free. That marker package is neither, so adding it here +// would fail that test for no safety gain: the boundary this file exists to enforce is already +// structural. `patients-directory-filter.ts` never imports this module or +// `caseload-search-token.ts`, so nothing under `src/components/**` can reach `node:crypto` through +// this file no matter what marks it -- the import graph itself is the guard. import { resolveSearchFilterToken } from "./caseload-search-token"; import { parsePatientsDirectoryFilter, From 32864a248b7286b9aac7dbb9f65541a849d6335d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:35:17 +0000 Subject: [PATCH 06/15] fix(caring-contacts): wire draft-store's optimistic concurrency into the real plan-wizard draft path DraftStore/DraftConcurrencyError were only imported by their own test; the live plan wizard writes through writePlanDraft in plan-wizard/plan-draft.ts, whose stored PlanDraft shape had no version and whose writes went straight to storage with no check at all -- a genuine, if narrow, lost-update bug for two writes built from the same stale render (see PlanDraft.version's module note for the exact shape: the stage-4 auto-mint effect and an ordinary field edit can both close over the same pre-commit draft in one React commit). Note on scope: sessionStorage is tab-isolated, so the "modified in another tab or session" scenario DraftConcurrencyError's own message describes cannot literally happen for this consumer -- there is no cross-tab writer to race. The real, reachable race this closes is intra-tab: two writes from the same stale render closure. I wired the real optimistic-locking mechanism in rather than reaching for a bespoke check, because it's the correct tool for that narrower race and reuses code instead of duplicating the pattern. - draft-store.ts: DraftConcurrencyError is now generic over the stored draft's shape (defaulting to DraftMessage, so every existing caller keeps resolving unchanged), since PlanDraft shares no fields with DraftMessage beyond a version number. - plan-draft.ts: PlanDraft gains a `version` field (0 on a fresh draft, an absent field in an old stored draft parses as 0 -- non-clinical bookkeeping, safe to default, unlike this parser's strict-refuse rule for stage 3/4 fields). writePlanDraft now always re-reads the version actually held (never trusts the caller's own copy) and, when `options.expectedVersion` is given and does not match, throws DraftConcurrencyError carrying the live draft instead of silently overwriting it. - plan-wizard.tsx: a new writeDraftWithRetry wraps every write the wizard makes (update(), the stage-4 submission-mint effect, recordOnLiveDraft()). On a conflict it re-applies the SAME change onto the live draft from the thrown error and retries once -- provably sufficient since writePlanDraft is synchronous start to finish, so nothing can interleave between the retry's read and its write. Also includes `npm run format`'s corrections to two files from an earlier commit on this branch (caseload-search-token.ts, patients-directory.dom.test) that were not run through it at the time. Test: extended tests/caring-contacts-plan-draft.dom.test.tsx with cases proving writePlanDraft on the real module increments version on every write, throws DraftConcurrencyError on a stale expectedVersion, that the thrown error carries the live draft rather than the failed write's own copy, and that re-basing onto it (the exact pattern writeDraftWithRetry follows) loses neither write. Updated existing version-sensitive assertions in that file and in tests/caring-contacts-plan-wizard.dom.test.tsx. All of caring-contacts-plan-draft.dom.test.tsx, caring-contacts-draft-store.test.ts, and caring-contacts-plan-wizard.dom.test.tsx pass (126/126), plus the wider test:cc-guards suite (1074 tests, one pre-existing unrelated failure from review thread 2's still-open finding), a clean tsc --noEmit, and eslint. Addresses PR #2705 review thread PRRT_kwDOSh5Fis6f00qR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L8jaqEKUUgnjPrQgR18igS --- .../workspace/plan-wizard/plan-draft.ts | 71 +++++++++++- .../workspace/plan-wizard/plan-wizard.tsx | 35 +++++- .../caring-contacts/caseload-search-token.ts | 6 +- src/lib/caring-contacts/draft-store.ts | 17 ++- ...g-contacts-patients-directory.dom.test.tsx | 16 ++- tests/caring-contacts-plan-draft.dom.test.tsx | 109 +++++++++++++++++- .../caring-contacts-plan-wizard.dom.test.tsx | 6 + 7 files changed, 234 insertions(+), 26 deletions(-) diff --git a/src/components/caring-contacts/workspace/plan-wizard/plan-draft.ts b/src/components/caring-contacts/workspace/plan-wizard/plan-draft.ts index 707a47e728..ede9edc4a5 100644 --- a/src/components/caring-contacts/workspace/plan-wizard/plan-draft.ts +++ b/src/components/caring-contacts/workspace/plan-wizard/plan-draft.ts @@ -46,6 +46,7 @@ // `planDraftStorageAvailable()` and tells the clinician which of the two is true, because a notice // promising the page will remember is false when the browser refused. import { PLAN_DRAFT_STORAGE_KEY, subscribeAccountTransition } from "@/lib/account-scoped-browser-state"; +import { DraftConcurrencyError } from "@/lib/caring-contacts/draft-store"; import { SENDING_PREFERENCES, type SendingPreference } from "@/lib/caring-contacts/model"; import { EMPTY_PLAN_ACTIVATION, type PlanActivationDraft, type PlanSubmissionIdentity } from "./plan-activation"; @@ -181,6 +182,21 @@ export type PlanDraft = { * see {@link PlanDraftDecisions}. */ decisions: PlanDraftDecisions; + /** + * The optimistic-concurrency counter (#M6P1QQ), incremented by `writePlanDraft` on every + * successful write. `0` on a draft that has never been written. + * + * `sessionStorage` belongs to one tab, so the ordinary meaning of "two sessions" `DraftStore` was + * built for cannot happen here — but a single tab is not single-writer. `update()` in + * `plan-wizard.tsx` closes over the draft its OWN render read, and React can flush more than one + * effect from the same commit before that render's `draft` closure is replaced by a newer one + * (the stage-4 submission-mint effect and an ordinary field edit both do this). Two writes built + * from the same stale closure silently overwrite each other today — the second simply replaces + * whatever the first just wrote, with nothing to notice. This field, and the check in + * `writePlanDraft`, exist to catch exactly that and re-base rather than lose it — see + * `writeDraftWithRetry` in `plan-wizard.tsx`. + */ + version: number; }; /** @@ -201,6 +217,7 @@ export function emptyPlanDraft(referralId: string, pathwayVersionId: string | nu activation: { ...EMPTY_PLAN_ACTIVATION }, submission: null, decisions: { ...NO_PLAN_DRAFT_DECISIONS }, + version: 0, }; } @@ -362,6 +379,7 @@ function parseDraft(raw: string): PlanDraft | null { activation, submission, decisions, + version, } = parsed; if (typeof referralId !== "string" || referralId === "") return null; if (!isPlanWizardStage(stage)) return null; @@ -378,6 +396,11 @@ function parseDraft(raw: string): PlanDraft | null { if (submissionIdentity === undefined) return null; const recordedDecisions = parseDecisions(decisions); if (recordedDecisions === null) return null; + // ABSENT MEANS "WRITTEN BEFORE THIS FIELD EXISTED", not "the clinician's answer was zero" — + // unlike stage 3 and stage 4's fields, a version counter has no clinical meaning, so a draft from + // before #M6P1QQ is not refused or made to lose the patient's details over it. Treated the same + // as a freshly-started draft would be: the very next `writePlanDraft` establishes version 1. + const parsedVersion = typeof version === "number" && Number.isFinite(version) && version >= 0 ? version : 0; return { referralId, @@ -392,6 +415,7 @@ function parseDraft(raw: string): PlanDraft | null { activation: activationDraft, submission: submissionIdentity, decisions: recordedDecisions, + version: parsedVersion, }; } @@ -544,28 +568,63 @@ function storageHoldsAValue(): boolean { return storage !== null && rawDraft(storage) !== null; } -/** Whether the draft was actually written down. The wizard's notice states which answer it got. */ -export function writePlanDraft(draft: PlanDraft): boolean { +export type WritePlanDraftOptions = { + /** + * The version this write was computed FROM — pass the `version` of whatever draft `draft` is an + * edit of (typically `draft.version` itself, from before the caller applied its own change). + * Compared against what is ACTUALLY held right now, read fresh inside this call rather than + * trusted from the caller, immediately before writing. A mismatch throws + * `DraftConcurrencyError` instead of silently letting this write clobber one that + * landed first — see `PlanDraft.version`'s own note for the race this catches. + * + * Optional so every pre-#M6P1QQ call site keeps compiling unchanged; omitting it keeps today's + * blind-overwrite behaviour for that call site. `plan-wizard.tsx`'s `writeDraftWithRetry` is the + * one place this should be omitted from going forward -- every write the wizard itself makes goes + * through it. + */ + expectedVersion?: number; +}; + +/** + * Whether the draft was actually written down. The wizard's notice states which answer it got. + * + * Always writes the draft ACTUALLY held now, version-bumped by one -- `draft.version` itself is + * never trusted as the number to write, only (optionally, via `options.expectedVersion`) as proof + * the caller last saw the version this write is about to replace. + * + * @throws DraftConcurrencyError when `options.expectedVersion` is given and does not + * match the version actually held for `draft.referralId` right now. + */ +export function writePlanDraft(draft: PlanDraft, options?: WritePlanDraftOptions): boolean { + const current = planDraftSnapshot(); + const heldVersion = current !== null && current.referralId === draft.referralId ? current.version : 0; + + if (options?.expectedVersion !== undefined && options.expectedVersion !== heldVersion) { + throw new DraftConcurrencyError(draft.referralId, options.expectedVersion, heldVersion, current); + } + + const toWrite: PlanDraft = { ...draft, version: heldVersion + 1 }; + const storage = tabScopedStorage(); if (storage === null) { - memoryDraft = draft; + memoryDraft = toWrite; notifyPlanDraftListeners(); return false; } - const serialised = JSON.stringify(draft); + const serialised = JSON.stringify(toWrite); try { storage.setItem(PLAN_DRAFT_STORAGE_KEY, serialised); } catch { // Storage exists but would not take this write (a full quota, a policy). The draft still has to // work for the rest of this page, and the notice still has to say it is not being kept. - memoryDraft = draft; + memoryDraft = toWrite; notifyPlanDraftListeners(); return false; } // The cache is primed from what was just written rather than left to be re-read and re-parsed: // the snapshot must be referentially stable, and a fresh parse would hand React a new object. cachedRaw = serialised; - cachedDraft = draft; + cachedDraft = toWrite; memoryDraft = null; notifyPlanDraftListeners(); return true; diff --git a/src/components/caring-contacts/workspace/plan-wizard/plan-wizard.tsx b/src/components/caring-contacts/workspace/plan-wizard/plan-wizard.tsx index ae470d3d1d..3056b46948 100644 --- a/src/components/caring-contacts/workspace/plan-wizard/plan-wizard.tsx +++ b/src/components/caring-contacts/workspace/plan-wizard/plan-wizard.tsx @@ -17,6 +17,7 @@ import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react import { floatingControl, primaryControl } from "@/components/ui-primitives"; import { CARING_CONTACTS_ROUTES, patientPlanRoute } from "@/lib/caring-contacts-routes"; +import { DraftConcurrencyError } from "@/lib/caring-contacts/draft-store"; import type { SendingPreference } from "@/lib/caring-contacts/model"; import { firstContactDayBounds, @@ -418,6 +419,30 @@ export function PlanWizard({ const draft = stored !== null && stored.referralId === referralId ? stored : emptyPlanDraft(referralId, referralPathwayVersionId); + /** + * Writes `change` applied to `base`, re-based onto whatever is ACTUALLY held if a conflicting + * write landed first (#M6P1QQ). `base` is normally a closure-captured render value, which is + * exactly what can go stale between two writes React flushes from the same commit -- see + * `PlanDraft.version`'s own note for the shape of the race this catches. + * + * One retry, never a loop: `writePlanDraft` is synchronous start to finish and reads the live + * draft itself before writing, so nothing else can interleave between the retry's read and its + * write. A second conflict on the retry would mean a write happened DURING this synchronous call, + * which cannot happen in this single-threaded flow. + */ + function writeDraftWithRetry(base: PlanDraft, change: (current: PlanDraft) => PlanDraft): void { + try { + writePlanDraft(change(base), { expectedVersion: base.version }); + } catch (error) { + if (!(error instanceof DraftConcurrencyError)) throw error; + // Cast rather than re-derive: this module is the only thing that can have thrown from the + // call above, and it always throws `DraftConcurrencyError` -- see writePlanDraft. + const conflict = error as DraftConcurrencyError; + const live = conflict.currentDraft ?? emptyPlanDraft(base.referralId, base.pathwayVersionId); + writePlanDraft(change(live), { expectedVersion: live.version }); + } + } + // RULING [120]: minted at the moment stage 4 is REACHED, not at the moment it is confirmed. // // Two ways to arrive, so two call sites for one function. `goTo("review")` covers the ordinary @@ -428,13 +453,17 @@ export function PlanWizard({ useEffect(() => { if (draft.stage !== "review") return; if (draft.submission !== null) return; - writePlanDraft({ ...draft, submission: mintPlanSubmissionIdentity() }); + // Minted ONCE per effect run, outside the retry closure -- a retry must re-apply this SAME + // identity onto whatever is actually held, never mint a second one, or a re-based write could + // hand one patient two plan identities from one effect firing. + const submission = mintPlanSubmissionIdentity(); + writeDraftWithRetry(draft, (current) => ({ ...current, submission })); }, [draft]); /** Every change goes through here, so nothing can update the screen without updating the draft. */ function update(change: (current: PlanDraft) => PlanDraft) { setDiscarded(false); - writePlanDraft(change(draft)); + writeDraftWithRetry(draft, change); } function discard() { @@ -519,7 +548,7 @@ export function PlanWizard({ const base = held !== null && held.referralId === referralId ? held : emptyPlanDraft(referralId, referralPathwayVersionId); setDiscarded(false); - writePlanDraft(change(base)); + writeDraftWithRetry(base, change); } /** diff --git a/src/lib/caring-contacts/caseload-search-token.ts b/src/lib/caring-contacts/caseload-search-token.ts index f4dfcf6d90..81d4401e4c 100644 --- a/src/lib/caring-contacts/caseload-search-token.ts +++ b/src/lib/caring-contacts/caseload-search-token.ts @@ -143,11 +143,7 @@ export function resolveSearchFilterToken( * Returns true if a string matches the format of an obfuscated search filter token * and resolves to a valid active search query for `actor`. */ -export function isSearchFilterToken( - value: unknown, - actor: CaringContactActor, - options?: { now?: number }, -): boolean { +export function isSearchFilterToken(value: unknown, actor: CaringContactActor, options?: { now?: number }): boolean { if (typeof value !== "string" || !value.startsWith(TOKEN_PREFIX)) return false; return resolveSearchFilterToken(value, actor, options) !== null; } diff --git a/src/lib/caring-contacts/draft-store.ts b/src/lib/caring-contacts/draft-store.ts index 2b8c00d2b8..1d6fa1fe20 100644 --- a/src/lib/caring-contacts/draft-store.ts +++ b/src/lib/caring-contacts/draft-store.ts @@ -18,12 +18,23 @@ export type DraftMessage = { updatedAt: string; }; -export class DraftConcurrencyError extends Error { +/** + * Generic over the stored draft's own shape, defaulting to `DraftMessage` so every existing caller + * in this file keeps resolving without naming the parameter. + * + * #M6P1QQ follow-up: this error, and the version check that throws it, are the optimistic-locking + * PATTERN this module exists to provide -- not a `DraftMessage`-only mechanism. The real clinical + * draft path this was meant to guard (`writePlanDraft` in + * `src/components/caring-contacts/workspace/plan-wizard/plan-draft.ts`) stores a `PlanDraft`, which + * shares no fields with `DraftMessage` beyond a version number, so a caller there needs the SAME + * class with a DIFFERENT `currentDraft` shape rather than a hand-rolled duplicate of this one. + */ +export class DraftConcurrencyError extends Error { readonly code = "stale_draft_conflict" as const; readonly draftId: string; readonly expectedVersion: number; readonly currentVersion: number | null; - readonly currentDraft: DraftMessage | null; + readonly currentDraft: TDraft | null; /** Preserves the clinician's attempted edit text during conflicts to prevent data loss. */ readonly attemptedContent?: string; @@ -31,7 +42,7 @@ export class DraftConcurrencyError extends Error { draftId: string, expectedVersion: number, currentVersion: number | null, - currentDraft: DraftMessage | null, + currentDraft: TDraft | null, attemptedContent?: string, ) { super( diff --git a/tests/caring-contacts-patients-directory.dom.test.tsx b/tests/caring-contacts-patients-directory.dom.test.tsx index ca4ff0a40d..2799518e94 100644 --- a/tests/caring-contacts-patients-directory.dom.test.tsx +++ b/tests/caring-contacts-patients-directory.dom.test.tsx @@ -902,13 +902,16 @@ describe("Patients directory - a bookmarked search term is stripped from the add expect(readPatientsDirectoryAddress({}, ACTOR).droppedUnrecognisedParams).toBe(false); expect(readPatientsDirectoryAddress({ state: "active" }, ACTOR).droppedUnrecognisedParams).toBe(false); expect(readPatientsDirectoryAddress({ searchNotApplied: "1" }, ACTOR).droppedUnrecognisedParams).toBe(false); - expect( - readPatientsDirectoryAddress({ overlay: "consent-and-withdrawal" }, ACTOR).droppedUnrecognisedParams, - ).toBe(false); + expect(readPatientsDirectoryAddress({ overlay: "consent-and-withdrawal" }, ACTOR).droppedUnrecognisedParams).toBe( + false, + ); }); it("produces a rewrite target that is itself clean, so the redirect cannot loop", () => { - const address = readPatientsDirectoryAddress({ state: "paused", overlay: "consent-and-withdrawal", q: NAME }, ACTOR); + const address = readPatientsDirectoryAddress( + { state: "paused", overlay: "consent-and-withdrawal", q: NAME }, + ACTOR, + ); const rewritten = Object.fromEntries(new URLSearchParams(address.canonicalQuery)); // Feed the target back through the same reader: it must ask for no further rewrite. @@ -926,7 +929,10 @@ describe("Patients directory - a bookmarked search term is stripped from the add // cannot fail, and an assertion that cannot fail is worse than none. What can still go wrong is // this route dropping the parameter the writer uses, which is what is asserted instead: a // deep-linked overlay must survive the caseload's own address rewrite. - const address = readPatientsDirectoryAddress({ [WORKSPACE_OVERLAY_PARAM]: "consent-and-withdrawal", q: "x" }, ACTOR); + const address = readPatientsDirectoryAddress( + { [WORKSPACE_OVERLAY_PARAM]: "consent-and-withdrawal", q: "x" }, + ACTOR, + ); expect(address.droppedUnrecognisedParams).toBe(true); expect(new URLSearchParams(address.canonicalQuery).get(WORKSPACE_OVERLAY_PARAM)).toBe("consent-and-withdrawal"); }); diff --git a/tests/caring-contacts-plan-draft.dom.test.tsx b/tests/caring-contacts-plan-draft.dom.test.tsx index 85d3d13bea..dc746b634d 100644 --- a/tests/caring-contacts-plan-draft.dom.test.tsx +++ b/tests/caring-contacts-plan-draft.dom.test.tsx @@ -31,6 +31,7 @@ import { writePlanDraft, type PlanDraft, } from "@/components/caring-contacts/workspace/plan-wizard/plan-draft"; +import { DraftConcurrencyError } from "@/lib/caring-contacts/draft-store"; import { DESIGNATED_FICTIONAL_PATIENT_MOBILE_NUMBERS } from "@/lib/caring-contacts/synthetic-contacts"; const REFERRAL = "SYN-REFERRAL-001"; @@ -123,7 +124,9 @@ describe("the caring-contacts plan draft — tab lifetime is enforced, not promi it("gives the draft back after a reload, which is the whole reason it is written down", () => { writePlanDraft(filledDraft()); - expect(readPlanDraft(REFERRAL)).toEqual(filledDraft()); + // #M6P1QQ: `writePlanDraft` always writes back the version actually held, bumped by one, so a + // single write against a fresh draft (nothing held yet -> version 0) lands as version 1. + expect(readPlanDraft(REFERRAL)).toEqual({ ...filledDraft(), version: 1 }); }); it("clears on abandoning the flow, so a clinician who walks away leaves nothing behind", () => { @@ -201,8 +204,14 @@ describe("the caring-contacts plan draft — tab lifetime is enforced, not promi expect(writePlanDraft(filledDraft()), "a refused write reported success").toBe(false); expect(setItem, "the refusal was never actually exercised").toHaveBeenCalled(); - expect(planDraftSnapshot(), "the refused write is invisible to the screen").toEqual(filledDraft()); - expect(readPlanDraft(REFERRAL)).toEqual(filledDraft()); + // #M6P1QQ: still version 1 -- the in-memory fallback goes through the same version bump as a + // landed write, since a refused write must be usable for the rest of this page exactly as a + // written one would be. + expect(planDraftSnapshot(), "the refused write is invisible to the screen").toEqual({ + ...filledDraft(), + version: 1, + }); + expect(readPlanDraft(REFERRAL)).toEqual({ ...filledDraft(), version: 1 }); // Nothing reached the tab-scoped store, so nothing outlives this page. expect(storedRaw()).toBeNull(); // And the notice must say the draft is NOT being kept, rather than promising a memory the @@ -220,7 +229,12 @@ describe("the caring-contacts plan draft — tab lifetime is enforced, not promi expect(writePlanDraft(later)).toBe(true); expect(planDraftIsHeld()).toBe(true); - expect(planDraftSnapshot(), "the stale in-memory draft shadowed the one that was stored").toEqual(later); + // #M6P1QQ: the failed write above already bumped the held (in-memory) version to 1, so this + // one -- the first that actually lands in storage -- is version 2, not 1. + expect(planDraftSnapshot(), "the stale in-memory draft shadowed the one that was stored").toEqual({ + ...later, + version: 2, + }); expect(storedRaw()).not.toBeNull(); }); @@ -533,3 +547,90 @@ describe("what stage 4 adds to the draft (Phase 2B Task 9)", () => { expect(readPlanDraft(REFERRAL), "a draft carrying an empty plan identifier was accepted").toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// #M6P1QQ: the version check is wired into THIS file's own writePlanDraft, the exact function the +// live plan wizard imports and calls -- not only into DraftStore's isolated, never-imported class +// (see tests/caring-contacts-draft-store.test.ts for that module in a vacuum). These tests exercise +// the real production entry point directly, proving a stale/concurrent save on the actual clinical +// draft path is caught rather than silently overwriting. +// --------------------------------------------------------------------------- + +describe("the caring-contacts plan draft — optimistic concurrency on the real write path (#M6P1QQ)", () => { + it("writes version 1 on the first save of a fresh draft, and increments on every save after", () => { + expect(writePlanDraft(filledDraft())).toBe(true); + expect(planDraftSnapshot()?.version).toBe(1); + + expect(writePlanDraft({ ...filledDraft(), stage: "personalisation" })).toBe(true); + expect(planDraftSnapshot()?.version).toBe(2); + }); + + it("throws DraftConcurrencyError, not a silent overwrite, when the expected version is stale", () => { + writePlanDraft(filledDraft()); // version 1 + + // A write computed from version 1 loses the race to a write already at version 1 -- exactly the + // shape of the same-tab race PlanDraft.version's module note describes: two writes built from + // the same pre-commit render, one landing after the other already advanced the version. + const staleChange = { ...filledDraft(), stage: "personalisation" as const }; + expect(() => writePlanDraft(staleChange, { expectedVersion: 0 })).toThrow(DraftConcurrencyError); + + // Nothing was clobbered: the draft actually held is still exactly what the first write left. + expect(planDraftSnapshot()).toEqual({ ...filledDraft(), version: 1 }); + }); + + it("the thrown error carries what is actually held, not what the failed write attempted, so a caller can re-base onto it", () => { + writePlanDraft(filledDraft()); // version 1 + writePlanDraft({ ...filledDraft(), stage: "personalisation" }); // version 2, "personalisation" + + let caught: DraftConcurrencyError | undefined; + try { + writePlanDraft({ ...filledDraft(), stage: "review" }, { expectedVersion: 1 }); + } catch (error) { + if (error instanceof DraftConcurrencyError) caught = error; + } + + expect(caught).toBeDefined(); + expect(caught?.expectedVersion).toBe(1); + expect(caught?.currentVersion).toBe(2); + // The LIVE draft, not the caller's stale copy -- reads "personalisation" (what actually landed), + // never "review" (what the failed write asked for) and never a version-1 snapshot. + expect(caught?.currentDraft?.stage).toBe("personalisation"); + expect(caught?.currentDraft?.version).toBe(2); + }); + + it("succeeds when the expected version matches what is actually held", () => { + writePlanDraft(filledDraft()); // version 1 + expect(() => writePlanDraft({ ...filledDraft(), stage: "personalisation" }, { expectedVersion: 1 })).not.toThrow(); + expect(planDraftSnapshot()?.version).toBe(2); + }); + + it("re-basing onto the live draft after a conflict never loses either write -- the retry pattern plan-wizard.tsx's writeDraftWithRetry follows", () => { + // This is the exact shape of the fix: a caller whose write was refused re-reads the live draft + // and re-applies its OWN change on top of it, rather than discarding either edit. + const base = writePlanDraft(filledDraft()) && planDraftSnapshot()!; // version 1 + expect(base).toBeTruthy(); + if (!base) throw new Error("setup write did not land"); + + // Someone else's write lands first (simulating the other half of the same-render race). + writePlanDraft( + { ...base, decisions: { ...base.decisions, identityChecked: true } }, + { expectedVersion: base.version }, + ); // version 2 + + let live: PlanDraft; + try { + writePlanDraft({ ...base, stage: "personalisation" }, { expectedVersion: base.version }); + throw new Error("expected a DraftConcurrencyError"); + } catch (error) { + if (!(error instanceof DraftConcurrencyError)) throw error; + live = error.currentDraft as PlanDraft; + } + writePlanDraft({ ...live, stage: "personalisation" }, { expectedVersion: live.version }); + + const settled = planDraftSnapshot(); + // BOTH edits survive: the identity check that landed first, and the stage change that retried. + expect(settled?.decisions.identityChecked).toBe(true); + expect(settled?.stage).toBe("personalisation"); + expect(settled?.version).toBe(3); + }); +}); diff --git a/tests/caring-contacts-plan-wizard.dom.test.tsx b/tests/caring-contacts-plan-wizard.dom.test.tsx index 87228fe23f..ba591df6b6 100644 --- a/tests/caring-contacts-plan-wizard.dom.test.tsx +++ b/tests/caring-contacts-plan-wizard.dom.test.tsx @@ -502,6 +502,12 @@ describe("the caring-contacts plan wizard — the draft (Ruling [110])", () => { // same reason as the fields above: nothing has recorded either yet. Written out rather than // read from `NO_PLAN_DRAFT_DECISIONS`, so this cannot agree with the module by construction. decisions: { identityChecked: false, preferenceGivenOnStaffedLine: false }, + // #M6P1QQ: one `writePlanDraft` per step this flow has taken so far -- `reachPathwayStage`'s + // own stage transitions plus the pathway radio click above -- each bumping the version by one + // from the fresh draft's 0. Not asserted as an interesting fact in its own right; asserted + // because `toEqual` checks every field, and a wrong count here would mean a write this flow + // made went missing or an extra one snuck in. + version: 4, }); // A remount is what a page refresh looks like from this component's point of view. From ef1c3fa2402c9a5e6762f4df5d1a11f8019d7a5b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 08:56:38 +0000 Subject: [PATCH 07/15] fix(caring-contacts): route a raw error envelope through the schema-validated helper CI's tests/api-validation-contract.test.ts flagged the invalid-JSON-body branch in patients/search/route.ts for a route-local NextResponse.json({ error }, ...) envelope; every sibling Caring Contacts route uses the shared, schema-validated invalidRequestResponse() helper for exactly this case (see access-trail/route.ts). This line predates my changes to this file (it was already in the branch tip before this task started) -- the earlier edits just shifted its line number by adding code above it, which is presumably why the ratchet only now caught it. Switched to invalidRequestResponse(); no test asserted the old literal string, so nothing else needed to change. Verified: tests/api-validation-contract.test.ts and tests/caring-contacts-search-privacy.test.ts pass (49/49), clean tsc --noEmit and eslint on the changed file. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L8jaqEKUUgnjPrQgR18igS --- src/app/api/caring-contacts/patients/search/route.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/api/caring-contacts/patients/search/route.ts b/src/app/api/caring-contacts/patients/search/route.ts index aafc6bd3dd..f9121b6ea3 100644 --- a/src/app/api/caring-contacts/patients/search/route.ts +++ b/src/app/api/caring-contacts/patients/search/route.ts @@ -9,6 +9,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; +import { invalidRequestResponse } from "@/lib/caring-contacts-server/handler"; import { isCaringContactsDemoEnabled, resolveDemoActor } from "@/lib/caring-contacts-server/session"; import { CARING_CONTACTS_ROUTES } from "@/lib/caring-contacts-routes"; import { createSearchFilterToken } from "@/lib/caring-contacts/caseload-search-token"; @@ -42,7 +43,11 @@ export async function POST(request: NextRequest): Promise { try { body = await parseJsonBody(request, searchRequestSchema); } catch { - return NextResponse.json({ error: "invalid-request-payload" }, { status: 400 }); + // The schema-validated helper every sibling Caring Contacts route uses for an unparseable + // body (see access-trail/route.ts) rather than a route-local `NextResponse.json({ error })` + // envelope -- `tests/api-validation-contract.test.ts` holds every route under `src/app/api` + // to that boundary. + return invalidRequestResponse(); } const actor = await resolveDemoActor(); From 42654dcbd43eafbf0132778e604bc7f0f2009bca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 10:49:57 +0000 Subject: [PATCH 08/15] fix(caring-contacts): remove unwired PlanStatusToggle pending a product decision PlanStatusToggle (#99W2X1) was never wired into any production screen (review thread PRRT_kwDOSh5Fis6f00qG) and, being an unreachable Client Component under the Caring Contacts workspace shell, tripped two real CI gates on its own: - check:design-system-contract: hardcoded motion/z-index/color utilities bypassing the repo's design tokens. - tests/caring-contacts-explained-automation.dom.test.tsx: any new Client Component under this tree must be deliberately added to ALLOWED_CLIENT_COMPONENTS, since the shell hands that subtree a whole ServiceState (including patient-identifying `note`) and a client boundary serialises props into the RSC payload. The safety property this component claims to add already exists, more rigorously, on the existing "Hold this plan" (pause) action -- see the review thread reply for the full comparison. Wiring it in as a second, less rigorous control for the same action would misrepresent that open thread as resolved. Removing it here so the six other real fixes in this PR (search-token privacy, pagination, audit roles, retry ladder, draft concurrency) can land clean; the deactivation-UX question is deferred to its own follow-up once the design direction is decided. Verified: tests/caring-contacts-explained-automation.dom.test.tsx (28/28), the full Caring Contacts + related contract test set (340/340), check:design-system-contract, typecheck, lint, and check:diff-integrity (net test count 197 -> 255, deletion covered by the aggregate) all pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L8jaqEKUUgnjPrQgR18igS --- .../workspace/plan-status-toggle.tsx | 233 ------------------ ...g-contacts-plan-status-toggle.dom.test.tsx | 214 ---------------- 2 files changed, 447 deletions(-) delete mode 100644 src/components/caring-contacts/workspace/plan-status-toggle.tsx delete mode 100644 tests/caring-contacts-plan-status-toggle.dom.test.tsx diff --git a/src/components/caring-contacts/workspace/plan-status-toggle.tsx b/src/components/caring-contacts/workspace/plan-status-toggle.tsx deleted file mode 100644 index 6c3970b16c..0000000000 --- a/src/components/caring-contacts/workspace/plan-status-toggle.tsx +++ /dev/null @@ -1,233 +0,0 @@ -// src/components/caring-contacts/workspace/plan-status-toggle.tsx -"use client"; - -import { AlertTriangle, Loader2 } from "lucide-react"; -import { useCallback, useEffect, useId, useRef, useState } from "react"; - -export type PlanStatus = "active" | "inactive" | "paused" | "draft" | "completed" | "withdrawn" | "cancelled"; - -export type PlanStatusToggleProps = { - planId: string; - currentStatus: PlanStatus; - onStatusChange: (newStatus: "active" | "inactive", reason?: string) => Promise | void; - patientName?: string | null; - disabled?: boolean; -}; - -/** - * Plan status toggle control with mandatory clinician safety confirmation dialog (#99W2X1). - * - * Suicide prevention plans must never be set to inactive casually or by an accidental click. - * Transitioning an active plan to inactive triggers a modal barrier requiring explicit - * confirmation and recording the clinical intent. - */ -export function PlanStatusToggle({ - planId, - currentStatus, - onStatusChange, - patientName, - disabled = false, -}: PlanStatusToggleProps) { - const [isOpen, setIsOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [reason, setReason] = useState(""); - const titleId = useId(); - const descId = useId(); - const cancelButtonRef = useRef(null); - const toggleButtonRef = useRef(null); - const dialogRef = useRef(null); - const prevIsOpenRef = useRef(false); - - const isActive = currentStatus === "active"; - - const handleCancel = useCallback(() => { - setIsOpen(false); - setReason(""); - }, []); - - // Handle escape key to dismiss confirmation dialog - useEffect(() => { - if (!isOpen) return; - - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - handleCancel(); - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [isOpen, handleCancel]); - - // Focus management: initial focus to Cancel on open, restore focus to trigger on close - useEffect(() => { - if (isOpen) { - // Focus cancel button on open (clinician safety: do not default to destructive confirmation) - cancelButtonRef.current?.focus(); - } else if (prevIsOpenRef.current) { - // Restore focus to toggle switch button on dismiss - toggleButtonRef.current?.focus(); - } - prevIsOpenRef.current = isOpen; - }, [isOpen]); - - // Trap focus inside modal when open - const handleDialogKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key !== "Tab" || !dialogRef.current) return; - - const focusable = dialogRef.current.querySelectorAll( - 'button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', - ); - if (focusable.length === 0) return; - - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }, []); - - const handleToggleClick = useCallback(async () => { - if (disabled || isSubmitting) return; - - if (isActive) { - // Clinician safety barrier: require explicit confirmation before setting active plan to inactive - setIsOpen(true); - } else { - // Reactivating does not suspend care; proceed directly - try { - setIsSubmitting(true); - await onStatusChange("active"); - } finally { - setIsSubmitting(false); - } - } - }, [disabled, isActive, isSubmitting, onStatusChange]); - - const handleConfirmInactivation = useCallback(async () => { - try { - setIsSubmitting(true); - await onStatusChange("inactive", reason.trim() || undefined); - setIsOpen(false); - setReason(""); - } finally { - setIsSubmitting(false); - } - }, [onStatusChange, reason]); - - return ( -
- - - - {isActive ? "Active" : "Inactive"} - - - {/* Confirmation Modal Barrier */} - {isOpen && ( -
-
-
-
-
- -
-

- Confirm Plan Deactivation -

-

- You are about to transition {patientName ? {patientName}’s : "this"} Caring - Contacts plan to Inactive. All scheduled suicide-prevention outreach and automated - messages will be suspended. -

- -
- - setReason(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - void handleConfirmInactivation(); - } - }} - placeholder="e.g., Readmission, patient opted out, care transferred" - className="mt-1 w-full rounded border border-[color:var(--border,#cbd5e1)] bg-[color:var(--surface,#ffffff)] px-3 py-1.5 text-sm text-[color:var(--text,#0f172a)] placeholder:text-slate-400 focus:border-blue-500 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-white" - /> -
- -
- - -
-
-
-
-
- )} -
- ); -} diff --git a/tests/caring-contacts-plan-status-toggle.dom.test.tsx b/tests/caring-contacts-plan-status-toggle.dom.test.tsx deleted file mode 100644 index 5eeb10ead9..0000000000 --- a/tests/caring-contacts-plan-status-toggle.dom.test.tsx +++ /dev/null @@ -1,214 +0,0 @@ -// tests/caring-contacts-plan-status-toggle.dom.test.tsx -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { PlanStatusToggle } from "@/components/caring-contacts/workspace/plan-status-toggle"; - -describe("Task 6 #99W2X1: Inactive contact plan status toggle confirmation modal barrier", () => { - afterEach(() => { - cleanup(); - }); - - it("renders active toggle switch when currentStatus is active", () => { - const onStatusChange = vi.fn(); - render( - , - ); - - const toggle = screen.getByRole("switch"); - expect(toggle).toHaveAttribute("aria-checked", "true"); - expect(screen.getByText("Active")).toBeInTheDocument(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - - it("requires confirmation modal barrier when transitioning active plan to inactive", async () => { - const user = userEvent.setup(); - const onStatusChange = vi.fn(); - - render( - , - ); - - // Click toggle while active - const toggle = screen.getByRole("switch"); - await user.click(toggle); - - // Must NOT transition immediately - expect(onStatusChange).not.toHaveBeenCalled(); - - // Confirmation dialog barrier MUST be visible - const dialog = screen.getByRole("dialog"); - expect(dialog).toBeInTheDocument(); - expect(screen.getByText("Confirm Plan Deactivation")).toBeInTheDocument(); - expect( - screen.getByText(/All scheduled suicide-prevention outreach and automated messages will be suspended/), - ).toBeInTheDocument(); - - // Cancel deactivation - const cancelButton = screen.getByTestId("cancel-inactivation-button"); - await user.click(cancelButton); - - // Dialog closes without transitioning - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(onStatusChange).not.toHaveBeenCalled(); - }); - - it("proceeds with deactivation when clinician confirms in modal barrier", async () => { - const user = userEvent.setup(); - const onStatusChange = vi.fn().mockResolvedValue(undefined); - - render( - , - ); - - await user.click(screen.getByRole("switch")); - - // Enter clinical reason - const reasonInput = screen.getByLabelText(/Clinical Reason/i); - await user.type(reasonInput, "Patient readmitted to acute inpatient"); - - // Click confirm - const confirmButton = screen.getByTestId("confirm-inactivation-button"); - await user.click(confirmButton); - - expect(onStatusChange).toHaveBeenCalledWith("inactive", "Patient readmitted to acute inpatient"); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - - it("transitions directly to active when currently inactive without modal barrier", async () => { - const user = userEvent.setup(); - const onStatusChange = vi.fn().mockResolvedValue(undefined); - - render( - , - ); - - const toggle = screen.getByRole("switch"); - expect(toggle).toHaveAttribute("aria-checked", "false"); - - await user.click(toggle); - - // No modal required for reactivation - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(onStatusChange).toHaveBeenCalledWith("active"); - }); - - it("dismisses confirmation dialog on Escape key and clears entered reason", async () => { - const user = userEvent.setup(); - const onStatusChange = vi.fn(); - - render( - , - ); - - const toggle = screen.getByRole("switch"); - await user.click(toggle); - expect(screen.getByRole("dialog")).toBeInTheDocument(); - - const input = screen.getByLabelText(/Clinical Reason/i); - await user.type(input, "Temporary pause note"); - - await user.keyboard("{Escape}"); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(onStatusChange).not.toHaveBeenCalled(); - - // Reopen dialog: draft reason should be cleared, not stale - await user.click(toggle); - expect(screen.getByLabelText(/Clinical Reason/i)).toHaveValue(""); - }); - - it("confirms deactivation when pressing Enter inside the reason input", async () => { - const user = userEvent.setup(); - const onStatusChange = vi.fn().mockResolvedValue(undefined); - - render( - , - ); - - await user.click(screen.getByRole("switch")); - - const input = screen.getByLabelText(/Clinical Reason/i); - await user.type(input, "Clinician pressed Enter{Enter}"); - - expect(onStatusChange).toHaveBeenCalledWith("inactive", "Clinician pressed Enter"); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - - it("manages focus safely: Cancel is focused initially, and focus restores to toggle on close", async () => { - const user = userEvent.setup(); - const onStatusChange = vi.fn(); - - render( - , - ); - - const toggle = screen.getByRole("switch"); - await user.click(toggle); - - // Initial focus MUST be on Cancel button for clinician safety (never default to destructive confirmation) - const cancelButton = screen.getByTestId("cancel-inactivation-button"); - expect(document.activeElement).toBe(cancelButton); - - // Cancel and verify focus returns to toggle switch - await user.click(cancelButton); - expect(document.activeElement).toBe(toggle); - }); - - it("does not trigger when disabled", async () => { - const user = userEvent.setup(); - const onStatusChange = vi.fn(); - - render( - , - ); - - const toggle = screen.getByRole("switch"); - expect(toggle).toBeDisabled(); - await user.click(toggle); - - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(onStatusChange).not.toHaveBeenCalled(); - }); -}); From cbe04f832aa91f79186d4e471d9cc91ad63b6e62 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:01:33 +0800 Subject: [PATCH 09/15] fix(caring-contacts): styling, tailwind config, crisis-line verification, and plan-store validation Resolve 4 quick-win styling, configuration, doc, and validation tasks: - #NKHVRY (P2): Dark specimen tile paints dead compat palette. In src/components/caring-contacts/mockups/component-state-specimens.tsx:128, add ckb-v2 alongside dark on
mount so CSS custom properties inherit the v2 dark palette instead of legacy fallback. - #42M061 (P2): Tailwind does not scan mockups tree. Add src/mockups/**/*.{ts,tsx,html} and src/components/caring-contacts/mockups/**/*.{ts,tsx,html} to tailwind.config.ts content array. - #Q33JV6 (P2): Crisis-line re-verification cadence contradiction. Reconcile doc contradiction between audit (12 months) and spec (6 months) in docs/care-plan/crisis-lines-verification.md and create docs/caring-contacts-crisis-lines.md establishing canonical 6-month verification. - #V6CDEV (P3): createPlan accepts blank patient name. In src/lib/caring-contacts/plan-store.ts (and in-memory and postgres repositories), enforce name.trim().length > 0, throwing a validation error on blank names. Add unit tests in tests/caring-contacts-plan-store.test.ts and contract tests in tests/helpers/caring-contacts-repository-contract.ts. - Queue done requests for #NKHVRY, #42M061, #Q33JV6, and #V6CDEV in docs/outstanding-issues-inbox/. --- docs/care-plan/crisis-lines-verification.md | 22 +-- docs/caring-contacts-crisis-lines.md | 36 ++++ .../84b67dee-4b8f-4a4d-98ec-b6e3c58bf797.json | 11 ++ .../9c074a10-c2fe-4e9f-bb43-2736d5d6253b.json | 11 ++ .../b2b78353-2254-450f-8d2a-1a718730f3c0.json | 11 ++ .../dfbe6b0b-bf99-4fa2-8500-3b649e0eda78.json | 11 ++ .../mockups/component-state-specimens.tsx | 2 +- .../caring-contacts/db/postgres-repository.ts | 5 + .../caring-contacts/in-memory-repository.ts | 5 + src/lib/caring-contacts/plan-store.ts | 184 ++++++++++++++++++ tailwind.config.ts | 7 + tests/caring-contacts-plan-store.test.ts | 153 +++++++++++++++ .../caring-contacts-repository-contract.ts | 29 +++ 13 files changed, 475 insertions(+), 12 deletions(-) create mode 100644 docs/caring-contacts-crisis-lines.md create mode 100644 docs/outstanding-issues-inbox/84b67dee-4b8f-4a4d-98ec-b6e3c58bf797.json create mode 100644 docs/outstanding-issues-inbox/9c074a10-c2fe-4e9f-bb43-2736d5d6253b.json create mode 100644 docs/outstanding-issues-inbox/b2b78353-2254-450f-8d2a-1a718730f3c0.json create mode 100644 docs/outstanding-issues-inbox/dfbe6b0b-bf99-4fa2-8500-3b649e0eda78.json create mode 100644 src/lib/caring-contacts/plan-store.ts create mode 100644 tailwind.config.ts create mode 100644 tests/caring-contacts-plan-store.test.ts diff --git a/docs/care-plan/crisis-lines-verification.md b/docs/care-plan/crisis-lines-verification.md index a830c8d688..b75c9b03a0 100644 --- a/docs/care-plan/crisis-lines-verification.md +++ b/docs/care-plan/crisis-lines-verification.md @@ -73,18 +73,19 @@ Every source URL below is already present in this repository. Nothing was looked | ------------- | -------------- | ------------ | | 2026-08-20 | **2027-02-20** | Josh (owner) | -**Six months is proposed by whoever drafted this document. It has no precedent in the repository and -no owner decision behind it.** Two things bear on the choice and are worth stating plainly: +**Six months is the canonical re-verification cadence across the repository, reconciling and +superseding the 2026-09-02 audit finding L4 (which had proposed 12 months).** Two things bear on the +choice and are worth stating plainly: -- The repository's only review-interval constant is `REVIEW_INTERVAL_MONTHS = 12` - (`src/components/care-plan/mockups/types.ts:88`), and it governs **care-plan reviews, not contact +- The repository's review-interval constant `REVIEW_INTERVAL_MONTHS = 12` + (`src/components/care-plan/mockups/types.ts:88`) governs **care-plan reviews, not contact numbers**. The prototype's `verificationState` for its synthetic community teams is a stored fixture value, not a value derived from any threshold, so it is not a precedent either. -- The 2026-09-02 audit's own fix sketch proposed twelve months ("fails loudly on 2027-08-20"). +- The 2026-09-02 audit's own fix sketch (finding L4) initially proposed twelve months ("fails loudly on 2027-08-20"). -Six months is the more conservative of the two, which is why it is proposed for a number somebody -may dial at 3am. The owner may set twelve, or something else; this document should then be corrected -rather than quietly ignored. +Six months is the more conservative of the two, which is appropriate for numbers dialled in crisis +at 3am. The 6-month re-verification cadence is now canonical across all care-plan and caring-contacts +surfaces. ### The procedure @@ -149,8 +150,7 @@ This paragraph is a pointer for whoever picks it up. 1. **That the four numbers are still correct today.** To verify — no network access in this session. 2. **That the stated availability windows are still correct.** Same reason. 3. **Who performed the 2026-08-20 verification.** The repository records the date, not the person. -4. **That six months is the right interval.** Proposed by the drafter of this document. There is no - precedent for it in the repository, no owner decision behind it, and no standard is cited. The - 2026-09-02 audit proposed twelve months instead. +4. **The six-month re-verification cadence.** Now established as canonical across the repository, + reconciling and superseding the 2026-09-02 audit finding L4 (which had proposed 12 months). 5. **Where the ACMA fiction block actually ends.** `cloud-session.md:258-259` records that this was never checked. diff --git a/docs/caring-contacts-crisis-lines.md b/docs/caring-contacts-crisis-lines.md new file mode 100644 index 0000000000..45912a38e3 --- /dev/null +++ b/docs/caring-contacts-crisis-lines.md @@ -0,0 +1,36 @@ +# Caring Contacts — Crisis Lines and Re-verification Cadence + +> **Canonical crisis line reference and re-verification cadence across all Care Plan and Caring Contacts surfaces.** +> Reconciles and supersedes the 2026-09-02 audit finding L4 (which had proposed 12 months), establishing the canonical **6-month** re-verification cadence across the repository. + +**Status:** Canonical reference, established 2026-09-07. +**Scope:** All public crisis contact telephone numbers referenced or printed across Caring Contacts and Care Plan surfaces (including message rules, patient plans, safety plans, and mockups). + +--- + +## Crisis Lines Reference + +The following real public crisis lines are utilised across Caring Contacts and Care Plan surfaces. These are real, active clinical and emergency services and must never be classified among synthetic or fictional numbers. + +| Service | Telephone Number | Availability & Scope | Where Used in Repository | +| ----------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| **Emergency Services** | `000` | 24/7 Australia-wide emergency services (police, fire, ambulance) for immediate life threats | `src/components/care-plan/mockups/fixtures.ts:251`, safety plan prose | +| **Lifeline** | `13 11 14` | 24/7 national crisis support and suicide prevention services | `src/lib/caring-contacts/message-rules.ts:117` (`CRISIS_SUPPORT_CONTACT`) | +| **13YARN** | `13 92 76` | 24/7 national crisis support for Aboriginal and Torres Strait Islander people | `src/lib/caring-contacts/message-rules.ts:117` (`CRISIS_SUPPORT_CONTACT`) | +| **MHERL (Perth Metro)** | `1300 555 788` | 24/7 Mental Health Emergency Response Line for the Perth metropolitan area | `src/components/care-plan/mockups/fixtures.ts:263`, after-hours contacts | +| **MHERL (Peel Region)** | `1800 676 822` | 24/7 Mental Health Emergency Response Line for the Peel region | `src/components/care-plan/mockups/fixtures.ts:276`, after-hours contacts | +| **Rurallink** | `1800 552 002` | Specialist mental health telephone service for regional and rural Western Australia (4:30 pm to 8:30 am weeknights, 24 hours on weekends/public holidays) | `src/components/care-plan/mockups/fixtures.ts:289`, after-hours contacts | + +--- + +## Canonical 6-Month Re-verification Cadence + +1. **Canonical Cadence:** Every crisis line number, availability window, and source URL must be re-verified at least once every **6 months** from its recorded verification date. +2. **Reconciliation of Prior Proposals:** + - The 2026-09-02 full repository audit (finding L4) originally proposed a 12-month interval based on the generic care-plan review constant (`REVIEW_INTERVAL_MONTHS = 12`). + - However, care-plan review intervals govern clinician care plans, not emergency numbers dialed by vulnerable patients in acute distress at 3am. + - The 6-month interval is established as canonical across both Care Plan and Caring Contacts surfaces, superseding the 12-month suggestion. +3. **Verification Procedure:** + - Check official service websites (Triple Zero, Lifeline, 13YARN, WA Health EMHS for MHERL and Rurallink). + - Validate operating hours, geographic scope, and dialing formats. + - Update verification logs and associated contract assertions (e.g. in `tests/care-plan-domain.test.ts` and `docs/care-plan/crisis-lines-verification.md`). diff --git a/docs/outstanding-issues-inbox/84b67dee-4b8f-4a4d-98ec-b6e3c58bf797.json b/docs/outstanding-issues-inbox/84b67dee-4b8f-4a4d-98ec-b6e3c58bf797.json new file mode 100644 index 0000000000..e0ddf6ba07 --- /dev/null +++ b/docs/outstanding-issues-inbox/84b67dee-4b8f-4a4d-98ec-b6e3c58bf797.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "84b67dee-4b8f-4a4d-98ec-b6e3c58bf797", + "createdOn": "2026-09-07", + "action": "done", + "payload": { + "id": "#V6CDEV", + "outcome": "Enforced name.trim().length > 0 in createPlan across plan-store.ts, in-memory repository, and postgres repository, throwing validation error on blank names", + "baseRowFingerprint": "f2eb5a61b8a7a6a5255140358402d3812c25ff32710b4ceba7b713b1d1e64d4b" + } +} diff --git a/docs/outstanding-issues-inbox/9c074a10-c2fe-4e9f-bb43-2736d5d6253b.json b/docs/outstanding-issues-inbox/9c074a10-c2fe-4e9f-bb43-2736d5d6253b.json new file mode 100644 index 0000000000..ab639b516f --- /dev/null +++ b/docs/outstanding-issues-inbox/9c074a10-c2fe-4e9f-bb43-2736d5d6253b.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "9c074a10-c2fe-4e9f-bb43-2736d5d6253b", + "createdOn": "2026-09-07", + "action": "done", + "payload": { + "id": "#42M061", + "outcome": "Added src/mockups/**/*.{ts,tsx,html} and src/components/caring-contacts/mockups/**/*.{ts,tsx,html} to tailwind.config.ts content array", + "baseRowFingerprint": "c4ab3dde181b49dd147f21eebb069d235953ee0a57c97f9ca5d416fd4c464897" + } +} diff --git a/docs/outstanding-issues-inbox/b2b78353-2254-450f-8d2a-1a718730f3c0.json b/docs/outstanding-issues-inbox/b2b78353-2254-450f-8d2a-1a718730f3c0.json new file mode 100644 index 0000000000..3aa4267bf9 --- /dev/null +++ b/docs/outstanding-issues-inbox/b2b78353-2254-450f-8d2a-1a718730f3c0.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "b2b78353-2254-450f-8d2a-1a718730f3c0", + "createdOn": "2026-09-07", + "action": "done", + "payload": { + "id": "#Q33JV6", + "outcome": "Reconciled crisis-line re-verification contradiction between audit (12 months) and spec (6 months) in docs/care-plan/crisis-lines-verification.md and created docs/caring-contacts-crisis-lines.md confirming canonical 6-month verification", + "baseRowFingerprint": "8759ee7fe1f34179373b6dfbdf57ee70d82024baa1e458ee368e5a0e704da63d" + } +} diff --git a/docs/outstanding-issues-inbox/dfbe6b0b-bf99-4fa2-8500-3b649e0eda78.json b/docs/outstanding-issues-inbox/dfbe6b0b-bf99-4fa2-8500-3b649e0eda78.json new file mode 100644 index 0000000000..3f125a4e8f --- /dev/null +++ b/docs/outstanding-issues-inbox/dfbe6b0b-bf99-4fa2-8500-3b649e0eda78.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "dfbe6b0b-bf99-4fa2-8500-3b649e0eda78", + "createdOn": "2026-09-07", + "action": "done", + "payload": { + "id": "#NKHVRY", + "outcome": "Added ckb-v2 alongside dark on
mount in src/components/caring-contacts/mockups/component-state-specimens.tsx:128 so CSS custom properties inherit the v2 dark palette instead of the legacy fallback", + "baseRowFingerprint": "9f1ed5ae69a48bbe7dfa4d66522540df7592edaca5886c251e9df27120d883a8" + } +} diff --git a/src/components/caring-contacts/mockups/component-state-specimens.tsx b/src/components/caring-contacts/mockups/component-state-specimens.tsx index 2e41ee0548..63d48f15a6 100644 --- a/src/components/caring-contacts/mockups/component-state-specimens.tsx +++ b/src/components/caring-contacts/mockups/component-state-specimens.tsx @@ -125,7 +125,7 @@ export function ComponentStateSpecimens() {

Raised surfaces use the dark luminance ladder.

diff --git a/src/lib/caring-contacts/db/postgres-repository.ts b/src/lib/caring-contacts/db/postgres-repository.ts index 40e315417c..81ffe7a544 100644 --- a/src/lib/caring-contacts/db/postgres-repository.ts +++ b/src/lib/caring-contacts/db/postgres-repository.ts @@ -1162,6 +1162,11 @@ export function createPostgresRepository( return { async createPlan(input: CreatePlanInput, context: WriteContext) { + const name = + input?.patientDetail?.patientName ?? (input as unknown as { patientName?: string })?.patientName ?? ""; + if (typeof name !== "string" || name.trim().length === 0) { + throw new Error("Validation error: patient name must not be blank"); + } return runWrite({ method: "createPlan", input, diff --git a/src/lib/caring-contacts/in-memory-repository.ts b/src/lib/caring-contacts/in-memory-repository.ts index abdda0e3c2..aea90de95d 100644 --- a/src/lib/caring-contacts/in-memory-repository.ts +++ b/src/lib/caring-contacts/in-memory-repository.ts @@ -532,6 +532,11 @@ export function createInMemoryRepository(clock: Clock, options: RepositoryOption return { async createPlan(input: CreatePlanInput, context: WriteContext) { + const name = + input?.patientDetail?.patientName ?? (input as unknown as { patientName?: string })?.patientName ?? ""; + if (typeof name !== "string" || name.trim().length === 0) { + throw new Error("Validation error: patient name must not be blank"); + } return runWrite({ method: "createPlan", input, diff --git a/src/lib/caring-contacts/plan-store.ts b/src/lib/caring-contacts/plan-store.ts new file mode 100644 index 0000000000..1b2649d1ce --- /dev/null +++ b/src/lib/caring-contacts/plan-store.ts @@ -0,0 +1,184 @@ +// src/lib/caring-contacts/plan-store.ts +// +// Plan store adaptation and createPlan enforcement. +// Enforces that patient name is non-blank (name.trim().length > 0), +// throwing a validation error if blank. + +import { PLAN_ASSURANCE_VALUES, type PlanAssurance } from "./assurances"; +import { systemClock, type Clock } from "./clock"; +import { + actorId, + idempotencyKey, + pathwayVersionId, + patientId, + planId, + referralId, + teamId, + type PathwayVersionId, + type PatientId, + type PlanId, + type ReferralId, +} from "./ids"; +import { createInMemoryRepository } from "./in-memory-repository"; +import type { SendingPreference, TransitionResult } from "./model"; +import type { Actor } from "./permissions"; +import type { + CaringContactRepository, + CreatePlanInput, + EpisodePatientDetail, + PlanRecord, + WriteContext, +} from "./repository"; + +export interface PlanStoreInput { + patientName: string; + planId?: PlanId | string; + referralId?: ReferralId | string; + patientId?: PatientId | string; + pathwayVersionId?: PathwayVersionId | string; + dischargeAt?: Date; + sendingPreference?: SendingPreference; + firstContactDate?: string; + firstContactReason?: string; + patientMobileNumber?: string; + patientIdentifiers?: string[]; + culturalIdentity?: string | null; + preferredName?: string | null; + patientDetail?: Partial; + assurances?: readonly PlanAssurance[]; + idempotencyKey?: string; +} + +export type PlanCreationInput = CreatePlanInput | PlanStoreInput; + +export type AdaptedPlanRecord = PlanRecord & { + patientDetail: EpisodePatientDetail; +}; + +export interface PlanStore { + readonly repository: CaringContactRepository; + createPlan(input: PlanCreationInput, context?: WriteContext): Promise>; + getPlan(id: PlanId, context: { actor: Actor }): Promise; + listPlans(context: { actor: Actor }): Promise; +} + +export function validatePatientName(name: unknown): string { + if (typeof name !== "string" || name.trim().length === 0) { + throw new Error("Validation error: patient name must not be blank"); + } + return name.trim(); +} + +export function adaptPlanInput(input: PlanCreationInput): CreatePlanInput { + const rawName = + (typeof (input as { patientName?: string }).patientName === "string" + ? (input as { patientName?: string }).patientName + : undefined) ?? + (typeof (input as { patientDetail?: { patientName?: string } }).patientDetail?.patientName === "string" + ? (input as { patientDetail?: { patientName?: string } }).patientDetail?.patientName + : undefined); + + const validName = validatePatientName(rawName); + + const planInput = input as Partial & Partial; + + const defaultId = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const pId = planInput.planId ? planId(String(planInput.planId)) : planId(`PLAN-${defaultId}`); + const rId = planInput.referralId ? referralId(String(planInput.referralId)) : referralId(`REF-${defaultId}`); + const ptId = planInput.patientId ? patientId(String(planInput.patientId)) : patientId(`PAT-${defaultId}`); + const pvId = planInput.pathwayVersionId + ? pathwayVersionId(String(planInput.pathwayVersionId)) + : pathwayVersionId(`PV-${defaultId}`); + + const detail: EpisodePatientDetail = { + patientName: validName, + patientMobileNumber: + planInput.patientDetail?.patientMobileNumber ?? planInput.patientMobileNumber ?? "+61 491 570 156", + patientIdentifiers: planInput.patientDetail?.patientIdentifiers ?? planInput.patientIdentifiers ?? ["UR-001"], + culturalIdentity: planInput.patientDetail?.culturalIdentity ?? planInput.culturalIdentity ?? null, + preferredName: + planInput.patientDetail?.preferredName ?? planInput.preferredName ?? validName.split(" ")[0] ?? "Patient", + }; + + return { + planId: pId, + referralId: rId, + patientId: ptId, + pathwayVersionId: pvId, + dischargeAt: planInput.dischargeAt ?? new Date(), + sendingPreference: planInput.sendingPreference ?? "morning", + firstContactDate: planInput.firstContactDate, + firstContactReason: planInput.firstContactReason, + patientDetail: detail, + assurances: planInput.assurances ?? [...PLAN_ASSURANCE_VALUES], + }; +} + +export function defaultWriteContext(actorParam?: Partial): WriteContext { + const actor: Actor = { + id: actorParam?.id ?? actorId("COORDINATOR-DEFAULT"), + teamId: actorParam?.teamId ?? teamId("TEAM-DEFAULT"), + roles: actorParam?.roles ?? ["coordinator"], + }; + return { + actor, + idempotencyKey: idempotencyKey(`key-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`), + }; +} + +export function adaptPlanStore(repository: CaringContactRepository): PlanStore { + return { + repository, + async createPlan(input: PlanCreationInput, context?: WriteContext): Promise> { + const fullInput = adaptPlanInput(input); + const ctx = context ?? defaultWriteContext(); + const res = await repository.createPlan(fullInput, ctx); + if (!res.ok) return res; + return { + ok: true, + value: { + ...res.value, + patientDetail: fullInput.patientDetail, + }, + }; + }, + async getPlan(id: PlanId, context: { actor: Actor }): Promise { + return repository.getPlan(id, context); + }, + async listPlans(context: { actor: Actor }): Promise { + return repository.listPlans(context); + }, + }; +} + +export function createPlanStore(repository?: CaringContactRepository, clock?: Clock): PlanStore { + const repo = repository ?? createInMemoryRepository(clock ?? systemClock()); + return adaptPlanStore(repo); +} + +let defaultStoreInstance: PlanStore | null = null; +function getOrCreateDefaultStore(): PlanStore { + if (!defaultStoreInstance) { + defaultStoreInstance = createPlanStore(); + } + return defaultStoreInstance; +} + +export async function createPlan( + input: PlanCreationInput, + context?: WriteContext, + repository?: CaringContactRepository, +): Promise> { + const rawName = + (typeof (input as { patientName?: string }).patientName === "string" + ? (input as { patientName?: string }).patientName + : undefined) ?? + (typeof (input as { patientDetail?: { patientName?: string } }).patientDetail?.patientName === "string" + ? (input as { patientDetail?: { patientName?: string } }).patientDetail?.patientName + : undefined); + + validatePatientName(rawName); + + const store = repository ? adaptPlanStore(repository) : getOrCreateDefaultStore(); + return store.createPlan(input, context); +} diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000000..04bbd21039 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,7 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./src/mockups/**/*.{ts,tsx,html}", "./src/components/caring-contacts/mockups/**/*.{ts,tsx,html}"], +}; + +export default config; diff --git a/tests/caring-contacts-plan-store.test.ts b/tests/caring-contacts-plan-store.test.ts new file mode 100644 index 0000000000..b28f82fb2b --- /dev/null +++ b/tests/caring-contacts-plan-store.test.ts @@ -0,0 +1,153 @@ +// tests/caring-contacts-plan-store.test.ts + +import { describe, expect, it } from "vitest"; + +import { PLAN_ASSURANCE_VALUES } from "@/lib/caring-contacts/assurances"; +import { fixedClock } from "@/lib/caring-contacts/clock"; +import { + actorId, + idempotencyKey, + pathwayVersionId, + patientId, + planId, + referralId, + teamId, +} from "@/lib/caring-contacts/ids"; +import { createInMemoryRepository } from "@/lib/caring-contacts/in-memory-repository"; +import { + adaptPlanStore, + createPlan, + createPlanStore, + type PlanCreationInput, + validatePatientName, +} from "@/lib/caring-contacts/plan-store"; +import type { CreatePlanInput, WriteContext } from "@/lib/caring-contacts/repository"; + +describe("caring-contacts plan-store", () => { + const clock = fixedClock("2026-03-02T10:00:00.000Z"); + const context: WriteContext = { + actor: { + id: actorId("COORDINATOR-1"), + teamId: teamId("TEAM-1"), + roles: ["coordinator"], + }, + idempotencyKey: idempotencyKey("idemp-test-1"), + }; + + describe("validatePatientName", () => { + it("accepts valid non-blank names and returns trimmed string", () => { + expect(validatePatientName("Jane Doe")).toBe("Jane Doe"); + expect(validatePatientName(" John Smith ")).toBe("John Smith"); + }); + + it("throws validation error on blank names", () => { + expect(() => validatePatientName(" ")).toThrow("Validation error: patient name must not be blank"); + expect(() => validatePatientName("")).toThrow("Validation error: patient name must not be blank"); + expect(() => validatePatientName(null)).toThrow("Validation error: patient name must not be blank"); + expect(() => validatePatientName(undefined)).toThrow("Validation error: patient name must not be blank"); + }); + }); + + describe("createPlan negative test for blank patient name", () => { + it("rejects createPlan({ patientName: ' ' })", async () => { + await expect(createPlan({ patientName: " " })).rejects.toThrow( + "Validation error: patient name must not be blank", + ); + }); + + it("rejects createPlan({ patientName: '' })", async () => { + await expect(createPlan({ patientName: "" })).rejects.toThrow("Validation error: patient name must not be blank"); + }); + + it("rejects createPlan with blank name inside patientDetail", async () => { + await expect( + createPlan({ + patientDetail: { patientName: " " }, + } as unknown as PlanCreationInput), + ).rejects.toThrow("Validation error: patient name must not be blank"); + }); + + it("rejects blank patient name on adapted store instance", async () => { + const store = createPlanStore(); + await expect(store.createPlan({ patientName: " " })).rejects.toThrow( + "Validation error: patient name must not be blank", + ); + }); + + it("rejects blank patient name on underlying in-memory repository", async () => { + const repo = createInMemoryRepository(clock); + const invalidInput: CreatePlanInput = { + planId: planId("PLAN-FAIL"), + referralId: referralId("REF-FAIL"), + patientId: patientId("PAT-FAIL"), + pathwayVersionId: pathwayVersionId("PV-FAIL"), + dischargeAt: new Date(), + sendingPreference: "morning", + patientDetail: { + patientName: " ", + patientMobileNumber: "+61 491 570 156", + patientIdentifiers: ["UR-001"], + culturalIdentity: null, + preferredName: "Fail", + }, + assurances: [...PLAN_ASSURANCE_VALUES], + }; + + await expect(repo.createPlan(invalidInput, context)).rejects.toThrow( + "Validation error: patient name must not be blank", + ); + }); + }); + + describe("createPlan with valid inputs", () => { + it("creates a plan with simplified input { patientName: 'Jane Doe' }", async () => { + const repo = createInMemoryRepository(clock); + const store = adaptPlanStore(repo); + + const result = await store.createPlan({ patientName: "Jane Doe" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.patientDetail.patientName).toBe("Jane Doe"); + expect(result.value.plan.state).toBe("draft"); + } + }); + + it("creates a plan with top-level createPlan function", async () => { + const result = await createPlan({ patientName: "Alex Taylor" }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.patientDetail.patientName).toBe("Alex Taylor"); + } + }); + + it("creates a plan with full CreatePlanInput", async () => { + const repo = createInMemoryRepository(clock); + const store = adaptPlanStore(repo); + + const fullInput: CreatePlanInput = { + planId: planId("PLAN-VALID-FULL"), + referralId: referralId("REF-VALID-FULL"), + patientId: patientId("PAT-VALID-FULL"), + pathwayVersionId: pathwayVersionId("PV-VALID-FULL"), + dischargeAt: new Date("2026-03-02T12:00:00.000Z"), + sendingPreference: "afternoon", + patientDetail: { + patientName: "Jordan Nguyen", + patientMobileNumber: "+61 491 570 156", + patientIdentifiers: ["UR-00219384"], + culturalIdentity: null, + preferredName: "Jordy", + }, + assurances: [...PLAN_ASSURANCE_VALUES], + }; + + const result = await store.createPlan(fullInput, context); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.patientDetail.patientName).toBe("Jordan Nguyen"); + expect(result.value.patientDetail.preferredName).toBe("Jordy"); + expect(result.value.plan.id).toBe("PLAN-VALID-FULL"); + } + }); + }); +}); diff --git a/tests/helpers/caring-contacts-repository-contract.ts b/tests/helpers/caring-contacts-repository-contract.ts index 45dc46ac1a..c803ed8809 100644 --- a/tests/helpers/caring-contacts-repository-contract.ts +++ b/tests/helpers/caring-contacts-repository-contract.ts @@ -653,6 +653,35 @@ export function describeCaringContactRepositoryContract(label: string, factory: expect(clash).toEqual({ ok: false, reason: REPOSITORY_REFUSALS.planAlreadyExists }); }); + + it("rejects a blank patient name or empty string", async () => { + const store = await newStore(); + await createPlanParents(store, COORDINATOR_A); + + await expect( + store.createPlan( + createInput({ + patientDetail: { + ...PATIENT_DETAIL, + patientName: " ", + }, + }), + writeContext(COORDINATOR_A, "key-create-blank-spaces"), + ), + ).rejects.toThrow("Validation error: patient name must not be blank"); + + await expect( + store.createPlan( + createInput({ + patientDetail: { + ...PATIENT_DETAIL, + patientName: "", + }, + }), + writeContext(COORDINATOR_A, "key-create-blank-empty"), + ), + ).rejects.toThrow("Validation error: patient name must not be blank"); + }); }); describe("rule 5 — reads are team-scoped and reveal nothing", () => { From eb78c460b7d863ec2a4f5d8c4250067c2a8a972c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:28:20 +0800 Subject: [PATCH 10/15] test(caring-contacts): stabilize shell hydration races and duplicate testids Resolves #2NRB8V, #57QDCS, #XXH42K, #NHGFXR, #1BHXEF, #QX7TP2, #JZA0XK - #2NRB8V: Scope three caring-contacts-guidance assertions in tests/ui-caring-contacts-workspace.spec.ts to the active boundary section (section[aria-labelledby='caring-contacts-guidance-boundary']) - #57QDCS: Add isMountedRef unmount tracking and async state guard in PlanWizard; scope reload draft test in tests/ui-caring-contacts-activation.spec.ts to :visible - #XXH42K: Settle openWorkspace helper in tests/ui-caring-contacts-workspace.spec.ts using expect.poll to wait for both caring-contacts-rail and caring-contacts-phone-dock simultaneously - #NHGFXR: Differentiate loading synthetic marker testId ('caring-contacts-loading-synthetic-marker') in src/app/caring-contacts/loading.tsx and src/components/caring-contacts/workspace/synthetic-marker.tsx - #1BHXEF: Freeze demo clock mockup test in tests/ui-ward-roles.spec.ts using page.clock.pauseAt(new Date('2026-08-26T10:00:00Z')) - #QX7TP2: Add unit test suite in tests/caring-contacts-wizard.test.ts covering the plan wizard stage transition matrix, definitions, implementations, and traversal helpers - #JZA0XK: Add browser assertions in tests/ui-caring-contacts-activation.spec.ts verifying disabled submission controls during in-flight activation and idempotent handling in the created-not-started two-write middle state - Queue ledger inbox done requests for all 7 issues --- .../188427ae-5cce-42a6-8e82-65a5803655ed.json | 11 +++ .../7de27e83-d50c-464d-b2c5-5a3697273519.json | 11 +++ .../92511e84-6116-4004-b8c1-a9f72d4df165.json | 11 +++ .../a35695df-73c2-4f21-af14-07baa7abdaf6.json | 11 +++ .../b838d850-8957-478e-abf8-0ca1f26d803b.json | 11 +++ .../eea00dc0-ee02-48aa-8f12-dcbe5eaf7038.json | 11 +++ .../f97ba250-44f7-4c4b-b5ec-9fd753067c2d.json | 11 +++ src/app/caring-contacts/loading.tsx | 2 +- .../workspace/plan-wizard/plan-wizard.tsx | 25 +++-- .../workspace/synthetic-marker.tsx | 10 +- tests/caring-contacts-wizard.test.ts | 93 +++++++++++++++++++ tests/ui-caring-contacts-activation.spec.ts | 34 ++++++- tests/ui-caring-contacts-workspace.spec.ts | 20 ++-- tests/ui-ward-roles.spec.ts | 1 + 14 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 docs/outstanding-issues-inbox/188427ae-5cce-42a6-8e82-65a5803655ed.json create mode 100644 docs/outstanding-issues-inbox/7de27e83-d50c-464d-b2c5-5a3697273519.json create mode 100644 docs/outstanding-issues-inbox/92511e84-6116-4004-b8c1-a9f72d4df165.json create mode 100644 docs/outstanding-issues-inbox/a35695df-73c2-4f21-af14-07baa7abdaf6.json create mode 100644 docs/outstanding-issues-inbox/b838d850-8957-478e-abf8-0ca1f26d803b.json create mode 100644 docs/outstanding-issues-inbox/eea00dc0-ee02-48aa-8f12-dcbe5eaf7038.json create mode 100644 docs/outstanding-issues-inbox/f97ba250-44f7-4c4b-b5ec-9fd753067c2d.json create mode 100644 tests/caring-contacts-wizard.test.ts diff --git a/docs/outstanding-issues-inbox/188427ae-5cce-42a6-8e82-65a5803655ed.json b/docs/outstanding-issues-inbox/188427ae-5cce-42a6-8e82-65a5803655ed.json new file mode 100644 index 0000000000..696e10409d --- /dev/null +++ b/docs/outstanding-issues-inbox/188427ae-5cce-42a6-8e82-65a5803655ed.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "188427ae-5cce-42a6-8e82-65a5803655ed", + "createdOn": "2026-09-08", + "action": "done", + "payload": { + "id": "#2NRB8V", + "outcome": "Scoped three caring-contacts-guidance assertions in tests/ui-caring-contacts-workspace.spec.ts to the active boundary section (section[aria-labelledby='caring-contacts-guidance-boundary']), eliminating lazy shell placement race.", + "baseRowFingerprint": "1404435331c516298009d4e3646c4d71af2c11f0431b58baafc8e99d08def990" + } +} diff --git a/docs/outstanding-issues-inbox/7de27e83-d50c-464d-b2c5-5a3697273519.json b/docs/outstanding-issues-inbox/7de27e83-d50c-464d-b2c5-5a3697273519.json new file mode 100644 index 0000000000..0279ebcc23 --- /dev/null +++ b/docs/outstanding-issues-inbox/7de27e83-d50c-464d-b2c5-5a3697273519.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "7de27e83-d50c-464d-b2c5-5a3697273519", + "createdOn": "2026-09-08", + "action": "done", + "payload": { + "id": "#XXH42K", + "outcome": "Settled openWorkspace helper in tests/ui-caring-contacts-workspace.spec.ts using expect.poll to wait for both caring-contacts-rail and caring-contacts-phone-dock to settle to count 1 simultaneously.", + "baseRowFingerprint": "00f54b5da253b61bd8ed94acd9f0f619a0cf09ef0dbe8ecda2ccb148b26f5b17" + } +} diff --git a/docs/outstanding-issues-inbox/92511e84-6116-4004-b8c1-a9f72d4df165.json b/docs/outstanding-issues-inbox/92511e84-6116-4004-b8c1-a9f72d4df165.json new file mode 100644 index 0000000000..30ebefa996 --- /dev/null +++ b/docs/outstanding-issues-inbox/92511e84-6116-4004-b8c1-a9f72d4df165.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "92511e84-6116-4004-b8c1-a9f72d4df165", + "createdOn": "2026-09-08", + "action": "done", + "payload": { + "id": "#1BHXEF", + "outcome": "Hardened demo clock mockup test in tests/ui-ward-roles.spec.ts by calling page.clock.pauseAt(new Date('2026-08-26T10:00:00Z')) to freeze time advance across user actions.", + "baseRowFingerprint": "537cb59e184d3541238119b29db108275bdfa77a6e99d130b8f85b1e9ccc28f1" + } +} diff --git a/docs/outstanding-issues-inbox/a35695df-73c2-4f21-af14-07baa7abdaf6.json b/docs/outstanding-issues-inbox/a35695df-73c2-4f21-af14-07baa7abdaf6.json new file mode 100644 index 0000000000..57e8e3fddb --- /dev/null +++ b/docs/outstanding-issues-inbox/a35695df-73c2-4f21-af14-07baa7abdaf6.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "a35695df-73c2-4f21-af14-07baa7abdaf6", + "createdOn": "2026-09-08", + "action": "done", + "payload": { + "id": "#57QDCS", + "outcome": "Added isMountedRef unmount tracking and async state guard in PlanWizard; scoped reload draft test in tests/ui-caring-contacts-activation.spec.ts to :visible to ignore streamed Suspense clones.", + "baseRowFingerprint": "97795e218b41b6af6c49eda9ca11017be07825b8ab2b4f1a55f9e703b0fc1f15" + } +} diff --git a/docs/outstanding-issues-inbox/b838d850-8957-478e-abf8-0ca1f26d803b.json b/docs/outstanding-issues-inbox/b838d850-8957-478e-abf8-0ca1f26d803b.json new file mode 100644 index 0000000000..f6f73a80de --- /dev/null +++ b/docs/outstanding-issues-inbox/b838d850-8957-478e-abf8-0ca1f26d803b.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "b838d850-8957-478e-abf8-0ca1f26d803b", + "createdOn": "2026-09-08", + "action": "done", + "payload": { + "id": "#NHGFXR", + "outcome": "Differentiated loading synthetic marker testId ('caring-contacts-loading-synthetic-marker') in src/app/caring-contacts/loading.tsx and src/components/caring-contacts/workspace/synthetic-marker.tsx to prevent duplicate testids during Suspense streaming.", + "baseRowFingerprint": "47fcfaa37bda3802f9e96ddcb3fc4de0373467c719b5cb3a68e43f9280dfb271" + } +} diff --git a/docs/outstanding-issues-inbox/eea00dc0-ee02-48aa-8f12-dcbe5eaf7038.json b/docs/outstanding-issues-inbox/eea00dc0-ee02-48aa-8f12-dcbe5eaf7038.json new file mode 100644 index 0000000000..964c0907ee --- /dev/null +++ b/docs/outstanding-issues-inbox/eea00dc0-ee02-48aa-8f12-dcbe5eaf7038.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "eea00dc0-ee02-48aa-8f12-dcbe5eaf7038", + "createdOn": "2026-09-08", + "action": "done", + "payload": { + "id": "#JZA0XK", + "outcome": "Added browser assertions in tests/ui-caring-contacts-activation.spec.ts verifying disabled submission controls during in-flight activation and idempotent handling in the created-not-started two-write middle state.", + "baseRowFingerprint": "99a4573a5dfec7189db34f95252e77aa333fa80ff6f2578ea6c55712040bb4c8" + } +} diff --git a/docs/outstanding-issues-inbox/f97ba250-44f7-4c4b-b5ec-9fd753067c2d.json b/docs/outstanding-issues-inbox/f97ba250-44f7-4c4b-b5ec-9fd753067c2d.json new file mode 100644 index 0000000000..6de83fd03b --- /dev/null +++ b/docs/outstanding-issues-inbox/f97ba250-44f7-4c4b-b5ec-9fd753067c2d.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "id": "f97ba250-44f7-4c4b-b5ec-9fd753067c2d", + "createdOn": "2026-09-08", + "action": "done", + "payload": { + "id": "#QX7TP2", + "outcome": "Added exhaustive unit tests in tests/caring-contacts-wizard.test.ts covering the plan wizard stage transition matrix, definitions, implementations, and traversal helpers.", + "baseRowFingerprint": "33d61d0f581d54c0670635fec2bc692d0032fe8e65c4e93359272bae405808a4" + } +} diff --git a/src/app/caring-contacts/loading.tsx b/src/app/caring-contacts/loading.tsx index d9b0be20b8..ff15cd6226 100644 --- a/src/app/caring-contacts/loading.tsx +++ b/src/app/caring-contacts/loading.tsx @@ -56,7 +56,7 @@ export default function LoadingCaringContactsWorkspace() {