Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
d5775ec
fix(clinical,teaching,roster): remediate adversarial audit findings a…
BigSimmo Oct 16, 2026
a9c3002
docs(ledger): record post-fix adversarial review for d5775ec60c
BigSimmo Oct 16, 2026
e916e31
fix: Validate supervisor link registration before sharing
qodo-code-review[bot] Oct 16, 2026
b867872
fix: 3 findings — Honor shared-device scratchpad privacy; Load scratchpa
qodo-code-review[bot] Oct 16, 2026
8a74f7e
fix: Show attendance export errors
qodo-code-review[bot] Oct 16, 2026
f9fc77c
fix(roster,teaching): format Qodo bot commits, align swap rule warnin…
BigSimmo Oct 16, 2026
ce4b645
fix(on-call): initialize scratchpadText via lazy useState initializer…
BigSimmo Oct 16, 2026
bdbeb84
fix(handover,notes,teaching,cli): resolve follow-up adversarial audit…
BigSimmo Oct 16, 2026
74a3d97
fix: 2 findings — Clear scratchpad on shared devices; Load scratchpad wi
qodo-code-review[bot] Oct 16, 2026
b0adab7
fix: 2 findings — Validate supplied form review content; Reject unverifi
qodo-code-review[bot] Oct 16, 2026
79a9f98
fix: 2 findings — Resync controlled note values; Preserve controlled not
qodo-code-review[bot] Oct 16, 2026
a5297f1
Merge main into fix/adversarial-remediation-20261016
claude Oct 16, 2026
8cbd707
Address 3576 review findings: queued note save, stored shift expiry, …
claude Oct 16, 2026
27c30ae
Keep Save now working on shared phones and reset the editor on a new …
claude Oct 16, 2026
cddfb89
Ignore a note save that finishes after the parent replaces the note
claude Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions scripts/publish-site-content-first-catalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,10 +495,17 @@ export async function runFirstPublicationCli(
env: NodeJS.ProcessEnv = process.env,
): Promise<FirstPublicationCataloguePlan> {
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({
Expand Down
5 changes: 3 additions & 2 deletions src/app/api/teaching/assessments/respond/[token]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,16 @@ 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", {
...noStore,
"Retry-After": String(rate.retryAfterSeconds),
});
}
const client = createAdminClient();
await requireAuthenticatedUser(request, client);
const { token } = await params;
const body = (await parseJsonBody(
request,
Expand Down
104 changes: 89 additions & 15 deletions src/components/clinical-notes/note-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<PatientDetailProblem | null>(() =>
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
checkPatientDetail(controlledValue ?? initialValue),
);
const currentValue = internalValue;
const sharedDevice = useSharedDevice();

const [privacyProblem, setPrivacyProblem] = useState<PatientDetailProblem | null>(() =>
checkPatientDetail(currentValue),
const [saveStatus, setSaveStatus] = useState<SaveStatus>(() =>
checkPatientDetail(controlledValue ?? initialValue) ? "error" : "idle",
);
const [saveStatus, setSaveStatus] = useState<SaveStatus>(() => (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);
}
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Resolved
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<string | null>(null);
const pendingSaveRef = useRef<string | null>(null);
const performSaveRef = useRef<(textToSave: string) => Promise<void>>(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<HTMLTextAreaElement>(null);
const saveTimerRef = useRef<NodeJS.Timeout | null>(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]);
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
useEffect(() => {
onChangeRef.current = onChange;
}, [onChange]);

// Store selection state to restore across re-renders/saves
const selectionRef = useRef<{
start: number;
Expand Down Expand Up @@ -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<HTMLTextAreaElement>) => {
const newValue = e.target.value;
recordSelection();

if (!isControlled) {
setInternalValue(newValue);
}
setInternalValue(newValue);

const problem = checkPatientDetail(newValue);
setPrivacyProblem(problem);
Expand All @@ -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
Expand Down Expand Up @@ -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") {
Expand All @@ -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 =
Expand Down
72 changes: 58 additions & 14 deletions src/components/on-call/handover/handover-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
ON_CALL_HANDOVER_NOT_SET_UP,
ON_CALL_HANDOVER_TYPES,
clearOnCallHandover,
readOnCallHandoverStoredExpiry,
emptyOnCallHandoverDraft,
HANDOVER_SCRATCHPAD_STORAGE_KEY,
loadOnCallHandoverScratchpad,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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) {
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
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;
Expand All @@ -653,14 +688,16 @@ export function OnCallHandoverPage() {
setProblem(null);
return;
}
let expiresAt = draftExpiresAt;
let expiresAt = shiftExpiresAt;
if (expiresAt === null) {
expiresAt = startPatientLabelRetention();
if (expiresAt === null) {
setProblem("The shift expiry could not be set. This handover cannot be kept on this device.");
return;
}
setDraftExpiresAt(expiresAt);
} else if (draftExpiresAt === null) {
setDraftExpiresAt(expiresAt);
}
const result = saveOnCallHandoverPatient(id, next);
if (!result.ok) {
Expand Down Expand Up @@ -759,7 +796,7 @@ export function OnCallHandoverPage() {
<h1 className={underBand ? "sr-only" : "text-2xl font-semibold text-[color:var(--text-heading)]"}>
Handover
</h1>
{patients.length > 0 && !confirmClear ? (
{(patients.length > 0 || scratchpadText.trim() !== "") && !confirmClear ? (
<button
type="button"
onClick={() => setConfirmClear(true)}
Expand All @@ -770,16 +807,18 @@ export function OnCallHandoverPage() {
</button>
) : null}
</div>
{patients.length > 0 && confirmClear ? (
{(patients.length > 0 || scratchpadText.trim() !== "") && confirmClear ? (
<div
className="grid min-w-0 gap-2 rounded-lg border border-[color:var(--border)] p-3 forced-colors:border"
role="group"
aria-label="Clear the handover"
>
<p className="text-sm text-[color:var(--text-heading)]">
{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.`}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button
Expand All @@ -793,7 +832,11 @@ export function OnCallHandoverPage() {
}}
testId="on-call-handover-clear-confirm"
>
{patients.length === 1 ? "Clear the patient" : `Clear all ${patients.length} patients`}
{patients.length === 0
? "Clear scratchpad"
: `${patients.length === 1 ? "Clear the patient" : `Clear all ${patients.length} patients`}${
scratchpadText.trim() !== "" ? " and scratchpad" : ""
}`}
</Button>
<Button
variant="ghost"
Expand Down Expand Up @@ -1011,14 +1054,15 @@ export function OnCallHandoverPage() {
// On a shared device only Save now keeps text, but clearing still removes any stored copy.
if (isSharedDevice() && text.trim() !== "") return;
try {
saveOnCallHandoverScratchpad(text);
persistScratchpad(text);
} catch {
// Handled by explicit save status
}
}}
onSave={(text) => {
// Save now is the deliberate save, so it keeps text even on a shared device.
setScratchpadText(text);
saveOnCallHandoverScratchpad(text);
persistScratchpad(text);
Comment thread
BigSimmo marked this conversation as resolved.
Resolved
}}
/>
</>
Expand Down
Loading
Loading