diff --git a/scripts/publish-site-content-first-catalogue.ts b/scripts/publish-site-content-first-catalogue.ts index 808d8a0a89..8c3a77c1cf 100644 --- a/scripts/publish-site-content-first-catalogue.ts +++ b/scripts/publish-site-content-first-catalogue.ts @@ -495,10 +495,17 @@ export async function runFirstPublicationCli( env: NodeJS.ProcessEnv = process.env, ): Promise { const options = parseFirstPublicationCliArgs(argv); - if (options.execute && env.ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT !== "true") { - throw new Error( - "Refusing live first-publication execution: --execute requires ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT=true.", - ); + if (options.execute) { + if (env.ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT !== "true") { + throw new Error( + "Refusing live first-publication execution: --execute requires ALLOW_DESTRUCTIVE_SITE_CONTENT_INIT=true.", + ); + } + if (!env.NEXT_PUBLIC_SUPABASE_URL?.trim() || !env.SUPABASE_SERVICE_ROLE_KEY?.trim()) { + throw new Error( + "Refusing live first-publication execution: NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are required for --execute (use --dry-run for offline plan generation).", + ); + } } const plan = buildFirstPublicationCataloguePlan({ diff --git a/src/app/api/teaching/assessments/respond/[token]/route.ts b/src/app/api/teaching/assessments/respond/[token]/route.ts index 14df4fc222..b36c250783 100644 --- a/src/app/api/teaching/assessments/respond/[token]/route.ts +++ b/src/app/api/teaching/assessments/respond/[token]/route.ts @@ -94,8 +94,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ toke export async function PUT(request: Request, { params }: { params: Promise<{ token: string }> }) { try { refuseEphemeralMagicLinkStore(); - const client = createAdminClient(); - await requireAuthenticatedUser(request, client); + // Rate limit before the sign-in lookup, so bad tokens cannot hammer the auth check. const rate = consumePublicApiRateLimit(request, "teaching-epa-magic-link-write"); if (rate.limited) { return errorWithHeaders("Too many requests. Wait a minute and try again.", 429, "rate_limited", { @@ -103,6 +102,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ toke "Retry-After": String(rate.retryAfterSeconds), }); } + const client = createAdminClient(); + await requireAuthenticatedUser(request, client); const { token } = await params; const body = (await parseJsonBody( request, diff --git a/src/components/clinical-notes/note-editor.tsx b/src/components/clinical-notes/note-editor.tsx index 708a484259..ad906808ef 100644 --- a/src/components/clinical-notes/note-editor.tsx +++ b/src/components/clinical-notes/note-editor.tsx @@ -43,23 +43,67 @@ export function ClinicalNoteEditor({ }: NoteEditorProps) { const instanceId = useId(); const isControlled = controlledValue !== undefined; - const [internalValue, setInternalValue] = useState(initialValue); - const currentValue = isControlled ? controlledValue : internalValue; + const [internalValue, setInternalValue] = useState(controlledValue ?? initialValue); + const [privacyProblem, setPrivacyProblem] = useState(() => + checkPatientDetail(controlledValue ?? initialValue), + ); + const currentValue = internalValue; const sharedDevice = useSharedDevice(); - const [privacyProblem, setPrivacyProblem] = useState(() => - checkPatientDetail(currentValue), + const [saveStatus, setSaveStatus] = useState(() => + checkPatientDetail(controlledValue ?? initialValue) ? "error" : "idle", ); - const [saveStatus, setSaveStatus] = useState(() => (checkPatientDetail(currentValue) ? "error" : "idle")); - const [lastSavedValue, setLastSavedValue] = useState(currentValue); + const [lastSavedValue, setLastSavedValue] = useState(controlledValue ?? initialValue); + + // Keep controlled updates as the source of truth when the parent changes them. + // Adjusted during render (not in an effect) so the new value shows in the same pass. + // A parent echoing what was just typed is not an external change, so it leaves the + // pending save alone. A genuinely new value is treated as the stored copy. + const [syncedControlledValue, setSyncedControlledValue] = useState(controlledValue); + const [externalValueVersion, setExternalValueVersion] = useState(0); + if (isControlled && controlledValue !== syncedControlledValue) { + setSyncedControlledValue(controlledValue); + if (controlledValue !== internalValue) { + const problem = checkPatientDetail(controlledValue); + setInternalValue(controlledValue); + setPrivacyProblem(problem); + setLastSavedValue(controlledValue); + setSaveStatus(problem ? "error" : "idle"); + setExternalValueVersion((version) => version + 1); + } + } + const hasSavedRef = useRef(false); + // Refs, not state, so a timer or a save finishing late compares against what is really stored. + const lastSavedRef = useRef(controlledValue ?? initialValue); + const inFlightSaveRef = useRef(null); + const pendingSaveRef = useRef(null); + const performSaveRef = useRef<(textToSave: string) => Promise>(async () => {}); + // Bumped when the parent supplies a new value, so a save started before it cannot touch the new note. + const saveGenerationRef = useRef(0); const textareaRef = useRef(null); const saveTimerRef = useRef(null); const latestValueRef = useRef(currentValue); + const onChangeRef = useRef(onChange); useEffect(() => { latestValueRef.current = currentValue; }, [currentValue]); + // After an external value arrives, drop any save queued for the old text. + useEffect(() => { + if (externalValueVersion === 0) return; + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + pendingSaveRef.current = null; + saveGenerationRef.current += 1; + lastSavedRef.current = latestValueRef.current; + }, [externalValueVersion]); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + // Store selection state to restore across re-renders/saves const selectionRef = useRef<{ start: number; @@ -123,32 +167,54 @@ export function ClinicalNoteEditor({ setSaveStatus("idle"); return; } - if (textToSave === lastSavedValue && saveStatus !== "error") return; + // One save at a time: a change made while a save is in flight is saved straight after it. + if (inFlightSaveRef.current !== null) { + pendingSaveRef.current = textToSave; + return; + } + if (textToSave === lastSavedRef.current && saveStatus !== "error") { + if (saveStatus === "unsaved") { + setSaveStatus(hasSavedRef.current ? "saved" : "idle"); + } + return; + } // Capture caret position before triggering save recordSelection(); setSaveStatus("saving"); + inFlightSaveRef.current = textToSave; + const generation = saveGenerationRef.current; try { await onSave(textToSave); + if (generation !== saveGenerationRef.current) return; + hasSavedRef.current = true; + lastSavedRef.current = textToSave; setLastSavedValue(textToSave); if (latestValueRef.current === textToSave) { setSaveStatus("saved"); } } catch { - setSaveStatus("error"); + if (generation === saveGenerationRef.current) setSaveStatus("error"); + } finally { + inFlightSaveRef.current = null; + const pending = pendingSaveRef.current; + pendingSaveRef.current = null; + if (pending !== null && pending === latestValueRef.current && pending !== lastSavedRef.current) { + void performSaveRef.current(pending); + } } }, - [readOnly, lastSavedValue, saveStatus, onSave, recordSelection], + [readOnly, saveStatus, onSave, recordSelection], ); + useEffect(() => { + performSaveRef.current = performSave; + }, [performSave]); const handleChange = (e: ChangeEvent) => { const newValue = e.target.value; recordSelection(); - - if (!isControlled) { - setInternalValue(newValue); - } + setInternalValue(newValue); const problem = checkPatientDetail(newValue); setPrivacyProblem(problem); @@ -174,6 +240,11 @@ export function ClinicalNoteEditor({ return; } + if (newValue === lastSavedRef.current && inFlightSaveRef.current === null) { + setSaveStatus(hasSavedRef.current ? "saved" : "idle"); + return; + } + setSaveStatus("unsaved"); // Suppress background auto-save on shared clinical devices @@ -224,11 +295,14 @@ export function ClinicalNoteEditor({ saveTimerRef.current = null; } selectionRef.current = { start: 0, end: 0, direction: "none" }; + hasSavedRef.current = false; setInternalValue(""); + lastSavedRef.current = ""; + pendingSaveRef.current = null; setLastSavedValue(""); setPrivacyProblem(null); setSaveStatus("idle"); - onChange?.(""); + onChangeRef.current?.(""); }; if (typeof window !== "undefined") { @@ -248,7 +322,7 @@ export function ClinicalNoteEditor({ window.removeEventListener(PATIENT_LABELS_CLEARED_EVENT, clearEditorState); } }; - }, [onChange]); + }, []); const showSaveControls = Boolean(onSave) || privacyProblem !== null || saveStatus === "error"; const describedBy = diff --git a/src/components/on-call/handover/handover-page.tsx b/src/components/on-call/handover/handover-page.tsx index 904c09f71a..7326042da5 100644 --- a/src/components/on-call/handover/handover-page.tsx +++ b/src/components/on-call/handover/handover-page.tsx @@ -43,6 +43,7 @@ import { ON_CALL_HANDOVER_NOT_SET_UP, ON_CALL_HANDOVER_TYPES, clearOnCallHandover, + readOnCallHandoverStoredExpiry, emptyOnCallHandoverDraft, HANDOVER_SCRATCHPAD_STORAGE_KEY, loadOnCallHandoverScratchpad, @@ -67,6 +68,7 @@ import { } from "@/lib/on-call/handover"; import { ON_CALL_SHIFT_PICK_TTL_MS, useOnCallShiftPick } from "@/lib/on-call/shift-context"; import { + clearExpiredPatientLabels, PATIENT_LABEL_EXPIRY_STORAGE_KEY, PATIENT_LABELS_CLEARED_EVENT, patientLabelStorageKey, @@ -615,13 +617,18 @@ export function OnCallHandoverPage() { }; }, [reset]); + const shiftExpiresAt = draftExpiresAt ?? view?.expiresAt ?? null; + // The draft's shift ends while the page is open: clear it on time, not at the next keystroke. useEffect(() => { - if (draftExpiresAt === null) return; + if (shiftExpiresAt === null) return; const check = () => { - if (Date.now() >= draftExpiresAt) reset(); + if (Date.now() >= shiftExpiresAt) { + clearExpiredPatientLabels(); + reset(); + } }; - const timer = window.setTimeout(check, Math.max(0, draftExpiresAt - Date.now())); + const timer = window.setTimeout(check, Math.max(0, shiftExpiresAt - Date.now())); window.addEventListener("focus", check); window.addEventListener("pageshow", check); document.addEventListener("visibilitychange", check); @@ -631,7 +638,7 @@ export function OnCallHandoverPage() { window.removeEventListener("pageshow", check); document.removeEventListener("visibilitychange", check); }; - }, [draftExpiresAt, reset]); + }, [shiftExpiresAt, reset]); // The record being edited vanished (deleted in another tab, or it lapsed): start a fresh one. const currentGone = currentId !== null && view !== null && !patients.some((patient) => patient.id === currentId); @@ -641,9 +648,37 @@ export function OnCallHandoverPage() { resetForm(); }, [currentGone, resetForm]); + // The view hides an expired shift, so a late timer also checks the stored stamp before writing. + const shiftHasEnded = useCallback(() => { + const expiresAt = shiftExpiresAt ?? readOnCallHandoverStoredExpiry(); + return expiresAt !== null && Date.now() >= expiresAt; + }, [shiftExpiresAt]); + + const persistScratchpad = useCallback( + (text: string) => { + if (shiftHasEnded()) { + clearExpiredPatientLabels(); + reset(); + setProblem("The shift has ended. This handover was cleared."); + return; + } + if (text.trim() !== "" && shiftExpiresAt === null) { + const started = startPatientLabelRetention(); + if (started === null) { + setProblem("The shift expiry could not be set. This handover cannot be kept on this device."); + throw new Error("The shift expiry could not be set."); + } + setDraftExpiresAt(started); + } + saveOnCallHandoverScratchpad(text); + }, + [shiftExpiresAt, shiftHasEnded, reset], + ); + /** Every change is kept as it is typed, so nothing is lost if the phone locks. */ const commit = (next: OnCallHandoverDraft, id: string | null = currentId) => { - if (draftExpiresAt !== null && Date.now() >= draftExpiresAt) { + if (shiftHasEnded()) { + clearExpiredPatientLabels(); reset(); setProblem("The shift has ended. This handover was cleared."); return; @@ -653,7 +688,7 @@ export function OnCallHandoverPage() { setProblem(null); return; } - let expiresAt = draftExpiresAt; + let expiresAt = shiftExpiresAt; if (expiresAt === null) { expiresAt = startPatientLabelRetention(); if (expiresAt === null) { @@ -661,6 +696,8 @@ export function OnCallHandoverPage() { return; } setDraftExpiresAt(expiresAt); + } else if (draftExpiresAt === null) { + setDraftExpiresAt(expiresAt); } const result = saveOnCallHandoverPatient(id, next); if (!result.ok) { @@ -759,7 +796,7 @@ export function OnCallHandoverPage() {

Handover

- {patients.length > 0 && !confirmClear ? ( + {(patients.length > 0 || scratchpadText.trim() !== "") && !confirmClear ? ( ) : null} - {patients.length > 0 && confirmClear ? ( + {(patients.length > 0 || scratchpadText.trim() !== "") && confirmClear ? (

- {patients.length === 1 - ? "Clear the one patient from this phone? This cannot be undone." - : `Clear all ${patients.length} patients from this phone? This cannot be undone.`} + {patients.length === 0 + ? "Clear the shift scratchpad from this phone? This cannot be undone." + : `Clear ${patients.length === 1 ? "the one patient" : `all ${patients.length} patients`}${ + scratchpadText.trim() !== "" ? " and the shift scratchpad" : "" + } from this phone? This cannot be undone.`}