Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
271601d
fix: correct --focus token and replace raw <a> with Link for internal…
cursoragent Jul 29, 2026
acc978c
fix: clear signed URL cache on auth identity change
cursoragent Jul 29, 2026
382278b
fix: address high-confidence bugs from recent PR review
cursoragent Jul 29, 2026
db8df7b
fix: redact full URLs before path shapes in diagnostics
cursoragent Jul 29, 2026
c8e75aa
docs(ledger): record last-100-PRs bug review outcomes
cursoragent Jul 29, 2026
e131dd0
test: drop unused userEvent setup in safety-plan draft case
cursoragent Jul 29, 2026
4b6f65f
Merge remote-tracking branch 'origin/main' into cursor/recent-pr-bugf…
cursoragent Jul 29, 2026
67c9922
fix: harden example guard, signed-URL paint clear, and UI locator
cursoragent Jul 29, 2026
85b7e38
docs(ledger): record PR #1374 CI fix and Codex P1 resolutions
cursoragent Jul 29, 2026
90eebbc
Merge branch 'main' into cursor/recent-pr-bugfixes-f30d
BigSimmo Jul 29, 2026
d9cb3b6
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] Jul 29, 2026
81765af
Merge remote-tracking branch 'origin/main' into cursor/recent-pr-bugf…
cursoragent Jul 29, 2026
2f48884
fix: clear signed-URL cache before account-switch session publish
cursoragent Jul 29, 2026
6cf35b6
docs(ledger): supersede PR #1374 CI-fix record after account-switch fix
cursoragent Jul 29, 2026
96eaf87
Merge origin/main into cursor/recent-pr-bugfixes-f30d
cursoragent Jul 29, 2026
bb1527b
docs: record PR #1374 main-merge staleness resolution
cursoragent Jul 29, 2026
35db321
Merge remote-tracking branch 'origin/main' into cursor/recent-pr-bugf…
claude Jul 29, 2026
2c424d9
Merge origin/main into cursor/recent-pr-bugfixes-f30d
cursoragent Jul 29, 2026
1d26698
docs: record PR #1374 main-merge staleness sync
cursoragent Jul 29, 2026
3d17383
docs: append PR review ledger record
Copilot Jul 29, 2026
2fb882b
Merge origin/main into cursor/recent-pr-bugfixes-f30d
claude Jul 29, 2026
173fd6e
fix: clear mounted document URLs on identity change; scope URL redaction
claude Jul 29, 2026
c0a8943
Merge origin/main into cursor/recent-pr-bugfixes-f30d
claude Jul 29, 2026
3c4e85d
docs(viewer): record why the identity reset does not reissue the preview
claude Jul 29, 2026
dc30c03
Merge origin/main into cursor/recent-pr-bugfixes-f30d
claude Jul 29, 2026
7199aaa
Merge origin/main into cursor/recent-pr-bugfixes-f30d
claude Jul 29, 2026
a6c88d5
docs(ledger): drop union-merge duplicates from the main merge
claude Jul 29, 2026
432f9fc
fix(privacy): redact apostrophes inside URL query values
claude Jul 29, 2026
c14edb9
fix(viewer): clear all identity-bound document state on an account sw…
claude Jul 29, 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
10 changes: 8 additions & 2 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/app/(search-app)/differentials/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function NotFound() {
<div className="mt-3 flex flex-wrap gap-2">
<Link
href={appModeHomeHref("differentials")}
className="text-sm font-medium text-blue-600 hover:underline"
className="text-sm font-medium text-[color:var(--clinical-accent)] hover:underline"
>
Return to differentials
</Link>
Expand Down
8 changes: 6 additions & 2 deletions src/app/(search-app)/documents/[id]/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ export default function NotFound() {
<div className="min-w-0">
<h1 className="text-base font-semibold text-[color:var(--text)]">Document Not Found</h1>
<p className="mt-1 leading-6 text-[color:var(--text-muted)]">
The requested document could not be found or has been deleted.
This document is unavailable. It may be private (sign in from the header if you have access), missing, or
removed.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link href={appModeHomeHref("documents")} className="text-sm font-medium text-blue-600 hover:underline">
<Link
href={appModeHomeHref("documents")}
className="text-sm font-medium text-[color:var(--clinical-accent)] hover:underline"
>
Return to document library
</Link>
</div>
Expand Down
14 changes: 13 additions & 1 deletion src/app/api/documents/[id]/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,24 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit);
}
const { data: document, error: documentError } = await withOwnerReadScope(
supabase.from("documents").select("id,metadata").eq("id", id),
supabase.from("documents").select("id,metadata,status").eq("id", id),
access.ownerId,
).maybeSingle();

if (documentError) throw new Error(documentError.message);
if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 });
// Match search_document_chunks: only indexed documents are searchable. Without
// this gate the portable_ilike fallback (admin client) can surface staged chunks
// from processing/failed docs when the RPC is unavailable.
if (document.status !== "indexed") {
return NextResponse.json({
query,
results: [],
pageHits: [],
hitCount: 0,
strategy: "document_not_indexed",
});
}
const committedGeneration = committedIndexGeneration(document.metadata);

const { data: rpcData, error: rpcError } = await supabase.rpc("search_document_chunks", {
Expand Down
8 changes: 5 additions & 3 deletions src/app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect, useRef, useState } from "react";
import { copyTextToClipboard } from "@/lib/copy-to-clipboard";
import { redactLogValue } from "@/lib/privacy";

/**
* Last-resort boundary for the App Router. Unlike `app/error.tsx`, this replaces
Expand Down Expand Up @@ -31,10 +32,11 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?
const handleCopyDiagnostics = () => {
const diagnosticPayload = {
name: error.name,
message: error.message,
message: redactLogValue(error.message),
digest: error.digest,
url: typeof window !== "undefined" ? window.location.href : "unknown",
userAgent: typeof window !== "undefined" ? window.navigator.userAgent : "unknown",
// Redact so clinical ?q= query text and secrets never leave via clipboard.
url: typeof window !== "undefined" ? redactLogValue(window.location.href) : "unknown",
userAgent: typeof window !== "undefined" ? redactLogValue(window.navigator.userAgent) : "unknown",
timestamp: new Date().toISOString(),
};
void copyTextToClipboard(JSON.stringify(diagnosticPayload, null, 2))
Expand Down
64 changes: 64 additions & 0 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,66 @@ export function DocumentViewer({
const [serverDemoMode, setServerDemoMode] = useState(
() => initialDetail?.demoMode ?? process.env.NEXT_PUBLIC_DEMO_MODE === "true",
);
// Drop every piece of mounted, identity-bound viewer state during render when the
// auth identity changes (sign-out / expiry / account switch). An auth-only
// transition leaves the document load key unchanged, so `isFullDocumentReload`
// below is false and the detail effect deliberately keeps the current window
// visible until the replacement request settles — which on a slow, offline, or
// denied request means user B reads user A's extracted private content. That
// covers the signed source URLs (bearer URLs whose module LRU cache is cleared
// separately, but whose resolved value the viewer also holds in its own state),
// and the detail payload itself: title, pages, images, table facts, chunks, index
// health, the generated summary, and the in-document search query + snippets.
//
// Keyed to the user id rather than `authorizationHeader` so a token refresh for
// the same clinician does not blank a document they are still entitled to read.
// Guarded on the PREVIOUS identity being non-null so the ordinary `null -> A`
// first-mount transition (auth resolving after hydration) does not throw away
// the server-rendered `initialDetail` on every page load; sign-out (`A -> null`)
// and account switch (`A -> B`) both still clear.
//
// Deliberately clears without reissuing. Forcing a reload here (by bumping
// `previewAttempt`) routes back through `openSourcePreview({ useCache: true })`,
// which reads the module signed-URL LRU — so it would repaint the PREVIOUS
// identity's URL wherever that cache had not already been cleared, which is
// exactly the leak this reset exists to close. A blank preview that recovers
// on reload is the conservative failure; re-showing the prior clinician's
// document is not. The stranded-preview follow-up is tracked separately.
const viewerAuthIdentity = session?.user?.id ?? null;
const [seenViewerAuthIdentity, setSeenViewerAuthIdentity] = useState(viewerAuthIdentity);
// Latched once the identity changes: `initialDetail` was server-rendered for the
// identity that requested the page, and the detail effect's `useInitialResult`
// branch replays it whenever the route still matches the initial one. Without
// this the effect would re-apply user A's SSR payload to user B on the very
// re-run the identity change triggers, undoing the clear below.
const [initialDetailIdentityStale, setInitialDetailIdentityStale] = useState(false);
if (viewerAuthIdentity !== seenViewerAuthIdentity) {
setSeenViewerAuthIdentity(viewerAuthIdentity);
setSignedUrl(null);
Comment thread
BigSimmo marked this conversation as resolved.
setDownloadSignedUrl(null);
Comment thread
BigSimmo marked this conversation as resolved.
if (seenViewerAuthIdentity !== null) {
setInitialDetailIdentityStale(true);
setDocument(null);
setPages([]);
setImages([]);
setTableFacts([]);
setChunks([]);
setIndexHealth(null);
setSummary(null);
setSummaryError(null);
setSourceSearch("");
setDocumentSearchState({ query: "", results: [] });
setDocumentSearchError(null);
setViewerError(null);
setPreviewError(null);
setDownloadError(null);
// The detail effect re-runs for the new identity (its deps include
// `authorizationHeader`) and clears this in its `finally`; showing the
// loading state meanwhile is what keeps the gap from reading as "this
// document is empty".
setLoadingDocument(true);
}
}
const localNoAuthMode = isLocalNoAuthMode();
const clientDemoMode = localNoAuthMode || serverDemoMode;
const canViewSourceDocuments = localProjectReady;
Expand Down Expand Up @@ -536,6 +596,9 @@ export function DocumentViewer({
previewAttempt === 0 &&
matchesInitialRoute &&
!navigatedFromInitialRouteRef.current &&
// `initialDetail` belongs to whoever the page was server-rendered for.
// Once the auth identity has changed it must be refetched, never replayed.
!initialDetailIdentityStale &&
Boolean(initialDetail || initialError);

detailControllerRef.current?.abort();
Expand Down Expand Up @@ -718,6 +781,7 @@ export function DocumentViewer({
previewAttempt,
initialDetail,
initialError,
initialDetailIdentityStale,
openSourcePreview,
applyPreviewSignedUrlResult,
]);
Expand Down
13 changes: 7 additions & 6 deletions src/components/clinical-dashboard/document-search-results.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { memo, useMemo, useState } from "react";
import Link from "next/link";
import {
BookOpen,
Clock3,
Expand Down Expand Up @@ -529,17 +530,17 @@ function SearchRecordResults({
<p className="text-2xs font-bold uppercase tracking-[0.06em] text-[color:var(--text-muted)]">
{service.catalogueLabel ?? "Source-backed record"}
</p>
<a
<Link
href={recordRoute(service.slug)}
className="mt-0.5 inline-flex min-h-tap items-center text-base font-semibold leading-6 text-[color:var(--text-heading)] transition hover:text-[color:var(--clinical-accent)] sm:min-h-7"
>
<span className="line-clamp-2">{service.title}</span>
</a>
</Link>
<p className={cn("mt-1 line-clamp-2 text-sm leading-6", textMuted)}>
{service.subtitle ?? service.bestUse ?? service.route ?? "Open the source-backed record."}
</p>
</div>
<a
<Link
href={recordRoute(service.slug)}
className={cn(
floatingControl,
Expand All @@ -549,7 +550,7 @@ function SearchRecordResults({
>
<ExternalLink className="h-4 w-4" aria-hidden="true" />
Open
</a>
</Link>
</div>

{chips.length ? (
Expand Down Expand Up @@ -937,12 +938,12 @@ function DocumentSearchResultsPanelImpl({
</>
) : null}
</p>
<a
<Link
href={openHref}
className="mt-0.5 inline-flex min-h-tap items-center rounded-md text-base-minus font-semibold leading-5 text-[color:var(--text-heading)] transition hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-7 sm:text-base sm:leading-6"
>
<span className="line-clamp-2">{documentDisplayTitle(document)}</span>
</a>
</Link>
</div>
</div>
<div className="mt-1 flex flex-wrap gap-1.5 sm:mt-1.5">
Expand Down
18 changes: 17 additions & 1 deletion src/components/clinical-dashboard/use-signed-image-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,23 @@ export function useSignedImageUrl(endpoint: string, enabled: boolean) {
const [url, setUrl] = useState(() => getCachedSignedUrl(endpoint)?.url ?? null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
const { authorizationHeader, markSessionExpired } = useAuthSession();
const { authorizationHeader, session, markSessionExpired } = useAuthSession();
// Drop painted URLs during render when the auth *identity* changes (sign-out /
// expiry / account switch). Auth also clears the module LRU; without this,
// mounted consumers keep showing the prior user's URL until refetch settles.
//
// Keyed to the user id, not `authorizationHeader`: the header is a new object
// on every access-token refresh, which would blank an image the same user is
// still entitled to see and force a needless refetch. The id changes in the
// same render as the header on a real identity change, so this is no later
// than the header-keyed version was — it just ignores refreshes.
const authIdentity = session?.user?.id ?? null;
const [seenAuthIdentity, setSeenAuthIdentity] = useState(authIdentity);
if (authIdentity !== seenAuthIdentity) {
setSeenAuthIdentity(authIdentity);
setUrl(null);
setFailed(false);
}

useEffect(() => {
if (!enabled) return () => undefined;
Expand Down
53 changes: 44 additions & 9 deletions src/components/patient-safety-plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ import {
* live patient-facing preview updates as they type — ready to print, save as
* PDF, or hand over. Working content stays in this mounted browser component;
* the app neither stores it nor sends it to a server. Copy and print are
* explicit user-directed exports. Sample content is seeded so the layout reads
* fully; every field is editable and "Clear all" empties the plan.
* explicit user-directed exports. Plans start blank; "Load example" can demo
* the layout with sample content that stays non-shareable until edited.
* Australian English + AU crisis resources throughout, per the Clinical KB
* (en-AU) voice. All chrome is token-driven so light/dark, reduced-motion and
* forced-colors follow the shared design system.
Expand Down Expand Up @@ -207,7 +207,8 @@ const SEED_REASONS: Entry[] = [

// Production default: a fresh plan starts blank so no sample/placeholder content
// (including the non-working example crisis numbers) can reach a printed handover.
// "Load example" restores the SEED content on demand for demos and training.
// "Load example" restores SEED for demos/training but keeps the plan non-shareable
// (example watermark, Finalise disabled) until every seeded row is removed/replaced.
const EMPTY_ENTRIES: Record<StepKey, Entry[]> = {
warning: [],
coping: [],
Expand All @@ -217,6 +218,20 @@ const EMPTY_ENTRIES: Record<StepKey, Entry[]> = {
environment: [],
};

const SEED_ENTRY_IDS = new Set([
...Object.values(SEED).flatMap((rows) => rows.map((entry) => entry.id)),
...SEED_REASONS.map((entry) => entry.id),
]);

function planContainsSeedEntries(entries: Record<StepKey, Entry[]>, reasons: Entry[]): boolean {
for (const rows of Object.values(entries)) {
for (const entry of rows) {
if (SEED_ENTRY_IDS.has(entry.id)) return true;
}
}
return reasons.some((entry) => SEED_ENTRY_IDS.has(entry.id));
}

/* ---------- small building blocks ---------- */

function AddRow({
Expand Down Expand Up @@ -461,7 +476,6 @@ export function PatientSafetyPlan() {
const [copied, setCopied] = useState(false);
const [finalised, setFinalised] = useState(false);
const [draftDirtyByRow, setDraftDirtyByRow] = useState<Record<string, boolean>>({});

// Per-instance id counter — avoids a module-level mutable that would persist
// across remounts; ids only need to be unique within this mounted plan.
const uidRef = useRef(0);
Expand All @@ -488,7 +502,11 @@ export function PatientSafetyPlan() {
}, []);

const filledSteps = useMemo(() => STEPS.filter((step) => isStepComplete(step, entries[step.key])).length, [entries]);
const ready = filledSteps === STEPS.length;
// SEED includes non-working crisis numbers. Stay in example mode while any
// seeded row remains so a date-only edit cannot make fake contacts printable.
const exampleActive = planContainsSeedEntries(entries, reasons);
const stepsComplete = filledSteps === STEPS.length;
const ready = stepsComplete && !exampleActive;
// Working plan content is browser-tab only and never persisted. Treat any
// entered step/reason/date as dirty so the header back control cannot discard
// an in-progress plan without an explicit confirmation.
Expand All @@ -502,8 +520,13 @@ export function PatientSafetyPlan() {
);

const planText = useMemo(() => {
const guardLines = exampleActive
? ["*** EXAMPLE — SAMPLE SAFETY PLAN WITH NON-WORKING NUMBERS, NOT FOR PATIENT HANDOVER ***", ""]
: ready
? []
: ["*** DRAFT — INCOMPLETE SAFETY PLAN, NOT FOR PATIENT HANDOVER ***", ""];
const lines: string[] = [
...(ready ? [] : ["*** DRAFT — INCOMPLETE SAFETY PLAN, NOT FOR PATIENT HANDOVER ***", ""]),
...guardLines,
"MY SAFETY PLAN",
"Name (add after export): ____________________",
planDate ? `Date: ${planDate}` : "",
Expand All @@ -527,7 +550,7 @@ export function PatientSafetyPlan() {
lines.push("In an emergency: call 000 or go to your nearest Emergency Department.");
lines.push("24/7 support: Lifeline 13 11 14 · Suicide Call Back Service 1300 659 467.");
return lines.filter((line, index, all) => !(line === "" && all[index - 1] === "")).join("\n");
}, [entries, planDate, ready, reasons]);
}, [entries, exampleActive, planDate, ready, reasons]);

const copyPlan = async () => {
try {
Expand Down Expand Up @@ -622,7 +645,11 @@ export function PatientSafetyPlan() {
ready ? toneSuccess : toneNeutral,
)}
>
{ready ? "Ready to share" : `${filledSteps}/${STEPS.length} steps`}
{ready
? "Ready to share"
: exampleActive
? "Example — not for handover"
: `${filledSteps}/${STEPS.length} steps`}
</span>
</div>
</div>
Expand Down Expand Up @@ -857,7 +884,15 @@ export function PatientSafetyPlan() {

{/* The plan document */}
<article className="grid content-start gap-5 rounded-xl border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] p-5 shadow-[var(--shadow-lux)] sm:p-6">
{ready ? null : (
{exampleActive ? (
<p
role="note"
className="rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] px-3 py-2 text-sm-minus font-bold leading-5 text-[color:var(--warning)]"
>
Example only — this sample plan uses non-working contact numbers. Replace every entry with the
patient&apos;s real details before sharing or printing.
</p>
) : ready ? null : (
<p
role="note"
className="rounded-lg border border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] px-3 py-2 text-sm-minus font-bold leading-5 text-[color:var(--warning)]"
Expand Down
10 changes: 6 additions & 4 deletions src/components/route-error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TriangleAlert, RefreshCw, ClipboardCopy, Check } from "lucide-react";

import { cn, primaryControl } from "@/components/ui-primitives";
import { copyTextToClipboard } from "@/lib/copy-to-clipboard";
import { redactLogValue } from "@/lib/privacy";

export type RouteErrorBoundaryProps = {
/** The error thrown by the segment, forwarded by Next.js. */
Expand Down Expand Up @@ -58,10 +59,11 @@ export function RouteErrorBoundary({
const handleCopyDiagnostics = () => {
const diagnosticPayload = {
name: error.name,
message: error.message,
message: redactLogValue(error.message),
digest: error.digest,
url: typeof window !== "undefined" ? window.location.href : "unknown",
userAgent: typeof window !== "undefined" ? window.navigator.userAgent : "unknown",
// Redact so clinical ?q= query text and secrets never leave via clipboard.
url: typeof window !== "undefined" ? redactLogValue(window.location.href) : "unknown",
userAgent: typeof window !== "undefined" ? redactLogValue(window.navigator.userAgent) : "unknown",
timestamp: new Date().toISOString(),
};
void copyTextToClipboard(JSON.stringify(diagnosticPayload, null, 2))
Expand Down Expand Up @@ -92,7 +94,7 @@ export function RouteErrorBoundary({
<h1
ref={headingRef}
tabIndex={-1}
className="mt-4 text-lg font-semibold tracking-tight text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus-ring,Highlight)]"
className="mt-4 text-lg font-semibold tracking-tight text-[color:var(--text-heading)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
>
{title}
</h1>
Expand Down
Loading
Loading