From d1732131244949f6e86b75ff1c998d2ff6236795 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 18 Jul 2026 06:15:15 +0000 Subject: [PATCH] fix: land unique open-PR queue onto current main Replay the remaining unique work from the screenshot PR set onto an up-to-date main branch: Also matches placement after primary results, patient factsheet slice, durable audit metadata minimization (with numeric storageRemoved retention), answer-progress UI gate coverage, and global-error alert role. Avoids conflicted/behind individual PRs and the design-audit branches that still fail Production UI. Co-authored-by: BigSimmo --- docs/privacy-impact-assessment.md | 12 +- docs/site-map.md | 3 + playwright.config.ts | 4 +- scripts/ci-change-scope.mjs | 11 ++ scripts/generate-site-map.ts | 3 + src/app/api/documents/[id]/route.ts | 8 +- src/app/api/upload/route.ts | 5 +- src/app/factsheets/[slug]/page.tsx | 18 +++ src/app/factsheets/layout.tsx | 7 + src/app/factsheets/page.tsx | 12 ++ src/app/factsheets/search/page.tsx | 9 ++ src/app/global-error.tsx | 1 + .../clinical-dashboard/differentials-home.tsx | 3 +- .../document-search-results.tsx | 3 +- .../favourites-command-library-page.tsx | 3 +- .../medication-prescribing-workspace.tsx | 3 +- .../factsheets/factsheet-detail-page.tsx | 109 +++++++++++++ src/components/factsheets/factsheet-shell.tsx | 37 +++++ src/components/factsheets/factsheets-data.ts | 84 ++++++++++ .../factsheets/factsheets-home-page.tsx | 118 ++++++++++++++ .../factsheets/factsheets-search-page.tsx | 144 ++++++++++++++++++ .../forms/forms-search-results-page.tsx | 2 +- .../formulation/formulation-home-page.tsx | 4 +- .../services/services-navigator-page.tsx | 2 +- src/lib/audit.ts | 39 ++++- ...0717163000_minimize_audit_log_metadata.sql | 33 ++++ tests/audit.test.ts | 66 ++++++++ tests/factsheets-data.test.ts | 14 ++ tests/ui-universal-search.spec.ts | 30 ++++ 29 files changed, 767 insertions(+), 20 deletions(-) create mode 100644 src/app/factsheets/[slug]/page.tsx create mode 100644 src/app/factsheets/layout.tsx create mode 100644 src/app/factsheets/page.tsx create mode 100644 src/app/factsheets/search/page.tsx create mode 100644 src/components/factsheets/factsheet-detail-page.tsx create mode 100644 src/components/factsheets/factsheet-shell.tsx create mode 100644 src/components/factsheets/factsheets-data.ts create mode 100644 src/components/factsheets/factsheets-home-page.tsx create mode 100644 src/components/factsheets/factsheets-search-page.tsx create mode 100644 supabase/migrations/20260717163000_minimize_audit_log_metadata.sql create mode 100644 tests/factsheets-data.test.ts diff --git a/docs/privacy-impact-assessment.md b/docs/privacy-impact-assessment.md index 70c8df18e..a8b5504d5 100644 --- a/docs/privacy-impact-assessment.md +++ b/docs/privacy-impact-assessment.md @@ -193,12 +193,12 @@ of the most important privacy items before real patient use (PIA-1). All three log tables are **owner-stamped** and **RLS-enabled** (owner-read for authenticated users; service-role for writes). Redaction is applied centrally at every write site. -| Table | Raw query stored? | Redaction mechanism | Other sensitive columns | RLS | -| -------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| `rag_queries` | No (hash placeholder) | `queryTextForStorage` / `normalizedQueryTextForStorage` ([query-privacy.ts:33-39](src/lib/query-privacy.ts)); centralized write in `insertRagQuery` | `answer` is null by default and stored only with explicit `RAG_PERSIST_ANSWER_TEXT=true`; `source_chunk_ids` (own data) | owner-read, [schema.sql:3932](supabase/schema.sql) | -| `rag_query_misses` | No (hash placeholder) | same helpers; writes in [search/route.ts:558-559](src/app/api/search/route.ts), [interaction/route.ts:88-89](src/app/api/search/interaction/route.ts) | `metadata.query_hash` | owner-read, [schema.sql:3935](supabase/schema.sql) | -| `rag_retrieval_logs` | No (hash placeholder) | same helpers; write at [search/route.ts:556-559](src/app/api/search/route.ts) | retrieval telemetry only | owner-read, [schema.sql:3938](supabase/schema.sql) | -| `audit_logs` | N/A (no query text) | action/resource metadata only; error strings pass through `redactLogValue` ([privacy.ts:5-31](src/lib/privacy.ts)) | `owner_id`, `action`, `resource_id` | service-role-only, [schema.sql:3959](supabase/schema.sql) | +| Table | Raw query stored? | Redaction mechanism | Other sensitive columns | RLS | +| -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| `rag_queries` | No (hash placeholder) | `queryTextForStorage` / `normalizedQueryTextForStorage` ([query-privacy.ts:33-39](src/lib/query-privacy.ts)); centralized write in `insertRagQuery` | `answer` is null by default and stored only with explicit `RAG_PERSIST_ANSWER_TEXT=true`; `source_chunk_ids` (own data) | owner-read, [schema.sql:3932](supabase/schema.sql) | +| `rag_query_misses` | No (hash placeholder) | same helpers; writes in [search/route.ts:558-559](src/app/api/search/route.ts), [interaction/route.ts:88-89](src/app/api/search/interaction/route.ts) | `metadata.query_hash` | owner-read, [schema.sql:3935](supabase/schema.sql) | +| `rag_retrieval_logs` | No (hash placeholder) | same helpers; write at [search/route.ts:556-559](src/app/api/search/route.ts) | retrieval telemetry only | owner-read, [schema.sql:3938](supabase/schema.sql) | +| `audit_logs` | N/A (no query text) | action/resource metadata only; the write boundary allowlists operational metadata and excludes user-controlled filenames/titles/content hashes ([audit.ts](../src/lib/audit.ts)). Migration `20260717163000` minimizes existing rows on deployment. | `owner_id`, `action`, `resource_id` | service-role-only, [schema.sql:3959](supabase/schema.sql) | ### 5.1 M15 HMAC query-hash fix — verified present, enforced in production diff --git a/docs/site-map.md b/docs/site-map.md index 3b7516445..f59686b4d 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -14,6 +14,9 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/dsm` - DSM-5 Diagnosis home. Source: `src/app/dsm/page.tsx`. - `/dsm/compare` - DSM diagnosis comparison. Source: `src/app/dsm/compare/page.tsx`. - `/dsm/search` - DSM diagnosis search and catalogue browser. Source: `src/app/dsm/search/page.tsx`. +- `/factsheets` - Patient information sheet library. Source: `src/app/factsheets/page.tsx`. +- `/factsheets/[slug]` - Patient information sheet detail. Source: `src/app/factsheets/[slug]/page.tsx`. +- `/factsheets/search` - Patient information sheet search. Source: `src/app/factsheets/search/page.tsx`. - `/favourites` - Saved clinical items and sets. Source: `src/app/favourites/page.tsx`. - `/forms` - Forms home and search surface. Source: `src/app/forms/page.tsx`. - `/formulation` - Clinical formulation home and local mechanism search surface. Source: `src/app/formulation/page.tsx`. diff --git a/playwright.config.ts b/playwright.config.ts index 268ccd250..4195dd79a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,14 +12,14 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // Tag-level filters keep production and prototype journeys disjoint even when // they share a spec file. const productionSpecPattern = - /.*ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|pwa)\.spec\.ts/; + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|pwa))\.spec\.ts/; const mockupSpecPattern = /.*ui-(tools|tools-collapse|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|pwa)\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|pwa))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index 2014a8e10..8c7383707 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -89,6 +89,11 @@ const uiPatterns = [ "src/components", "src/styles", "public", + // Answer progress is a production Playwright journey even though its + // historical filename does not start with `ui-`. Keep its CI trigger in + // lockstep with playwright.config.ts so an edited assertion cannot evade + // the required UI job (Vitest does not collect *.spec.ts files). + "tests/answer-progress-ui-smoke.spec.ts", /^tests\/ui-.*\.spec\.ts$/, /^tests\/playwright-.*\.ts$/, /^playwright(?:\..*)?\.config\.ts$/, @@ -420,6 +425,12 @@ function selfTest() { ui_changed: true, build_changed: true, }); + assertScope("answer-progress-playwright", ["tests/answer-progress-ui-smoke.spec.ts"], { + source_changed: true, + coverage_changed: true, + ui_changed: true, + build_changed: false, + }); assertScope("db", ["supabase/migrations/20260710000000_example.sql"], { db_changed: true, source_changed: true, diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 16b019bd8..8bea4b419 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -52,6 +52,9 @@ const routeDescriptions: Record = { "/documents/search": "Documents search command centre.", "/documents/source": "Compatibility redirect to the canonical live document viewer when a valid id is supplied.", "/documents/source/evidence": "Compatibility redirect sharing the canonical live document viewer handoff.", + "/factsheets": "Patient information sheet library.", + "/factsheets/[slug]": "Patient information sheet detail.", + "/factsheets/search": "Patient information sheet search.", "/favourites": "Saved clinical items and sets.", "/forms": "Forms home and search surface.", "/forms/[slug]": "Registry-backed form detail.", diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index abaa39321..a78a3c7f2 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -178,7 +178,9 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id action: "document_rename", resourceType: "document", resourceId: id, - metadata: { previousTitle: document.title, newTitle: body.title }, + // Audit the rename event and resource without retaining user-controlled + // titles indefinitely in the service-role audit log. + metadata: {}, }); return NextResponse.json({ document: updated }); } catch (error) { @@ -241,7 +243,9 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i action: "document_delete", resourceType: "document", resourceId: id, - metadata: { title: result.document_title, storageRemoved: cleanup.storageRemoved }, + // The deleted title can contain patient information. Retain only the + // operational cleanup result alongside the resource id and action. + metadata: { storageRemoved: cleanup.storageRemoved }, }); return NextResponse.json({ deleted: true, diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 8ff02fda8..cbcfc9cd9 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -307,7 +307,10 @@ export async function POST(request: Request) { action: "document_upload", resourceType: "document", resourceId: documentId, - metadata: { fileName: file.name, fileType: file.type, fileSize: file.size, contentHash }, + // `audit_logs` is retained indefinitely. Keep only operational facts there; + // the user-controlled filename and content hash remain on the scoped document + // record, not in the durable audit trail. + metadata: { fileType: file.type, fileSize: file.size }, }); return NextResponse.json({ document, job }, { status: 201 }); diff --git a/src/app/factsheets/[slug]/page.tsx b/src/app/factsheets/[slug]/page.tsx new file mode 100644 index 000000000..5c6b998cd --- /dev/null +++ b/src/app/factsheets/[slug]/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +import { FactsheetDetailPage } from "@/components/factsheets/factsheet-detail-page"; +import { findFactsheet } from "@/components/factsheets/factsheets-data"; + +export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { + const { slug } = await params; + const factsheet = findFactsheet(slug); + return { title: factsheet ? `${factsheet.title} | Patient Information` : "Factsheet not found" }; +} + +export default async function FactsheetInfoPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const factsheet = findFactsheet(slug); + if (!factsheet) notFound(); + return ; +} diff --git a/src/app/factsheets/layout.tsx b/src/app/factsheets/layout.tsx new file mode 100644 index 000000000..5abf7581e --- /dev/null +++ b/src/app/factsheets/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +import { FactsheetShell } from "@/components/factsheets/factsheet-shell"; + +export default function FactsheetsLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/factsheets/page.tsx b/src/app/factsheets/page.tsx new file mode 100644 index 000000000..958b7f05d --- /dev/null +++ b/src/app/factsheets/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { FactsheetsHomePage } from "@/components/factsheets/factsheets-home-page"; + +export const metadata: Metadata = { + title: "Patient Information Sheets | Clinical KB", + description: "Browse patient information sheet layouts and approved local resources.", +}; + +export default function FactsheetsPage() { + return ; +} diff --git a/src/app/factsheets/search/page.tsx b/src/app/factsheets/search/page.tsx new file mode 100644 index 000000000..daf394964 --- /dev/null +++ b/src/app/factsheets/search/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from "next"; + +import { FactsheetsSearchPage } from "@/components/factsheets/factsheets-search-page"; + +export const metadata: Metadata = { title: "Search Patient Information | Clinical KB" }; + +export default function FactsheetsSearchRoute() { + return ; +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 2f3073164..aed84dad6 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -49,6 +49,7 @@ export default function GlobalError({ error, reset }: { error: Error & { digest?

-

) : null} + +

Clinical decision support only. Review before use.

diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index dce916dba..41889eb73 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -896,7 +896,6 @@ function DocumentSearchResultsPanelImpl({ onSortChange={setSortValue} /> - ) : null} {recordMatchCount > 0 || @@ -1140,6 +1139,8 @@ function DocumentSearchResultsPanelImpl({ )} + + {trimmedQuery && !shouldShowHome ? : null} ); } diff --git a/src/components/clinical-dashboard/favourites-command-library-page.tsx b/src/components/clinical-dashboard/favourites-command-library-page.tsx index 85463590d..4b84f5c53 100644 --- a/src/components/clinical-dashboard/favourites-command-library-page.tsx +++ b/src/components/clinical-dashboard/favourites-command-library-page.tsx @@ -1065,7 +1065,6 @@ export function FavouritesCommandLibraryPage({ query = "" }: { query?: string })
- )} + + {selectedItem ? setSelectedItemId(null)} /> : null} diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 873764d45..e7dd299aa 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -469,7 +469,6 @@ function MedicationResults({
-

Medication search

@@ -693,6 +692,8 @@ function MedicationResults({ })}
+ + ("idle"); + const shareButtonRef = useRef(null); + + async function copyLink() { + try { + await navigator.clipboard.writeText(window.location.href); + setCopyStatus("copied"); + } catch { + setCopyStatus("error"); + } + } + + return ( +
+ +
+ ); +} diff --git a/src/components/factsheets/factsheet-shell.tsx b/src/components/factsheets/factsheet-shell.tsx new file mode 100644 index 000000000..4ec509473 --- /dev/null +++ b/src/components/factsheets/factsheet-shell.tsx @@ -0,0 +1,37 @@ +"use client"; + +import Link from "next/link"; +import { BookOpenText, Search } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn, navPill } from "@/components/ui-primitives"; + +export function FactsheetShell({ children }: { children: ReactNode }) { + return ( +
+
+
+ + + + Patient information + + +
+
+
{children}
+
+ ); +} diff --git a/src/components/factsheets/factsheets-data.ts b/src/components/factsheets/factsheets-data.ts new file mode 100644 index 000000000..8deee0a34 --- /dev/null +++ b/src/components/factsheets/factsheets-data.ts @@ -0,0 +1,84 @@ +export type Factsheet = { + slug: string; + title: string; + summary: string; + topic: string; + audience: string; + readTime: string; + updated: string; + sections: Array<{ heading: string; body: string }>; +}; + +/** + * Intentionally non-clinical sample content for the factsheet UI slice. These + * records demonstrate the layout without presenting unreviewed patient advice. + */ +export const factsheets: Factsheet[] = [ + { + slug: "appointment-planning", + title: "Planning a follow-up appointment", + summary: "A simple, patient-friendly structure for preparing questions and sharing what matters.", + topic: "Appointments", + audience: "Patients and supporters", + readTime: "3 min read", + updated: "Sample content", + sections: [ + { + heading: "Before you arrive", + body: "Use this space for approved local information about preparing for an appointment, including what a patient may want to bring or ask.", + }, + { + heading: "Questions that matter to you", + body: "A factsheet can help patients capture concerns in their own words and agree on the next step with their care team.", + }, + { + heading: "After the conversation", + body: "Add locally approved contact details, review timing, and support options here before publishing this factsheet.", + }, + ], + }, + { + slug: "support-network", + title: "Involving your support network", + summary: "A calm overview of choosing who to involve in a care conversation.", + topic: "Support", + audience: "Patients and supporters", + readTime: "2 min read", + updated: "Sample content", + sections: [ + { + heading: "Choose what feels helpful", + body: "Use approved local copy to explain options for inviting a trusted person to a conversation, while respecting privacy and patient choice.", + }, + { + heading: "Make a shared plan", + body: "A short, clear summary can help everyone understand the agreed next step and where to find further support.", + }, + ], + }, + { + slug: "care-plan-notes", + title: "Keeping notes about your care plan", + summary: "A practical page for recording agreed actions in plain language.", + topic: "Care planning", + audience: "Patients", + readTime: "2 min read", + updated: "Sample content", + sections: [ + { + heading: "Keep it clear", + body: "This sample section shows the intended reading rhythm. Replace it with approved, service-specific patient information before use.", + }, + { + heading: "Know where to get help", + body: "Include verified local contacts and escalation information only after governance review.", + }, + ], + }, +]; + +export const factsheetTopics = ["Appointments", "Support", "Care planning"] as const; + +export function findFactsheet(slug: string) { + return factsheets.find((factsheet) => factsheet.slug === slug); +} diff --git a/src/components/factsheets/factsheets-home-page.tsx b/src/components/factsheets/factsheets-home-page.tsx new file mode 100644 index 000000000..57a4eb9e5 --- /dev/null +++ b/src/components/factsheets/factsheets-home-page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import Link from "next/link"; +import { ArrowRight, FileText, Search } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { FormEvent, useState } from "react"; + +import { factsheets, factsheetTopics } from "@/components/factsheets/factsheets-data"; +import { + cn, + fieldControlWithIcon, + fieldIcon, + metadataPill, + primaryControl, + quietPanel, +} from "@/components/ui-primitives"; + +export function FactsheetsHomePage() { + const router = useRouter(); + const [query, setQuery] = useState(""); + + function submit(event: FormEvent) { + event.preventDefault(); + const value = query.trim(); + router.push(value ? `/factsheets/search?q=${encodeURIComponent(value)}` : "/factsheets/search"); + } + + return ( +
+
+

+ Patient information library +

+

+ Clear information for the next conversation. +

+

+ Find a short, plain-language factsheet to support a patient conversation. Every published sheet should be + sourced, locally approved, and easy to take away. +

+
+ +
+ +
+
+
+ +
+
+ +
+

+ Browse by topic +

+
+ {factsheetTopics.map((topic) => ( + + {topic} + + ))} +
+
+ +
+
+
+ +

Sample layouts only — not clinical guidance.

+
+ + View all sheets + +
+
+ {factsheets.map((factsheet) => ( + + + +

{factsheet.topic}

+

+ {factsheet.title} +

+

{factsheet.summary}

+

{factsheet.readTime}

+ + ))} +
+
+
+ ); +} diff --git a/src/components/factsheets/factsheets-search-page.tsx b/src/components/factsheets/factsheets-search-page.tsx new file mode 100644 index 000000000..06530543f --- /dev/null +++ b/src/components/factsheets/factsheets-search-page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import Link from "next/link"; +import { FileSearch, FileText, Search, X } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { FormEvent, useMemo, useState } from "react"; + +import { factsheets } from "@/components/factsheets/factsheets-data"; +import { + EmptyState, + cn, + fieldControlWithIcon, + fieldIcon, + metadataPill, + primaryControl, + quietPanel, +} from "@/components/ui-primitives"; + +export function FactsheetsSearchPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const submittedQuery = (searchParams.get("q") ?? "").trim(); + const [input, setInput] = useState(submittedQuery); + const [prevQuery, setPrevQuery] = useState(submittedQuery); + if (prevQuery !== submittedQuery) { + setPrevQuery(submittedQuery); + setInput(submittedQuery); + } + + const results = useMemo(() => { + const normalized = submittedQuery.toLowerCase(); + if (!normalized) return factsheets; + return factsheets.filter((factsheet) => + [factsheet.title, factsheet.summary, factsheet.topic, factsheet.audience] + .join(" ") + .toLowerCase() + .includes(normalized), + ); + }, [submittedQuery]); + + function submit(event: FormEvent) { + event.preventDefault(); + const query = input.trim(); + router.push(query ? `/factsheets/search?q=${encodeURIComponent(query)}` : "/factsheets/search"); + } + + return ( +
+

+ Find a sheet +

+

+ Search patient information +

+
+ +
+
+
+ +
+
+ +
+ {results.length === 0 ? ( +
+ +
+ + Browse sheets + +
+
+ ) : ( +
+
+

+ {submittedQuery + ? `${results.length} result${results.length === 1 ? "" : "s"} for "${submittedQuery}"` + : `${results.length} sample factsheets`} +

+ {submittedQuery ? ( + +
+
+ {results.map((factsheet) => ( + + + + + + + {factsheet.title} + + {factsheet.topic} + + + {factsheet.summary} + + + {factsheet.audience} · {factsheet.readTime} + + + + ))} +
+
+ )} +
+ +
+ ); +} diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 984dfc9cf..d1d6a40ea 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -652,7 +652,6 @@ function FormsSearchResultsPageContent({ query }: FormsSearchResultsPageProps) { onSortChange={setSortValue} />
- {query.trim() && displayedMatches.length === 0 ? ( )} + ) : null}
diff --git a/src/components/formulation/formulation-home-page.tsx b/src/components/formulation/formulation-home-page.tsx index a64b0e3fb..00f95761d 100644 --- a/src/components/formulation/formulation-home-page.tsx +++ b/src/components/formulation/formulation-home-page.tsx @@ -202,8 +202,6 @@ function FormulationResults({ query }: { query: string }) {

- -
)} + + ); diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index d4a3fc942..17097e554 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -515,7 +515,6 @@ export function ServicesNavigatorPage() { sortValue={sortValue} onSortChange={setSortValue} /> - } sidebar={ @@ -644,6 +643,7 @@ export function ServicesNavigatorPage() {
)} + ); } diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 8725ea4f5..9240f1904 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -15,6 +15,43 @@ export type AuditLogEntry = { metadata?: Record; }; +/** + * Audit rows are retained indefinitely, so their metadata must be an operational + * event summary rather than a second copy of document metadata. In particular, + * upload filenames and rename/delete titles are user-controlled and can contain + * patient identifiers. Keep this allowlist at the durable-write boundary so a + * future caller cannot accidentally reintroduce free text into `audit_logs`. + */ +function minimumAuditMetadata(entry: AuditLogEntry): Record { + const metadata = entry.metadata ?? {}; + + switch (entry.action) { + case "document_upload": { + const fileType = typeof metadata.fileType === "string" ? metadata.fileType : undefined; + const fileSize = + typeof metadata.fileSize === "number" && Number.isFinite(metadata.fileSize) ? metadata.fileSize : undefined; + return { + ...(fileType ? { fileType } : {}), + ...(fileSize !== undefined ? { fileSize } : {}), + }; + } + case "document_delete": { + // DELETE routes pass a removed-object count; older callers may pass a boolean. + const storageRemoved = metadata.storageRemoved; + if (typeof storageRemoved === "number" && Number.isFinite(storageRemoved)) { + return { storageRemoved }; + } + if (typeof storageRemoved === "boolean") { + return { storageRemoved }; + } + return {}; + } + case "document_rename": + case "document_label_change": + return {}; + } +} + // Minimal structural type so we do not couple to the full Supabase client type. type AuditInsertClient = { from: (table: string) => { @@ -29,7 +66,7 @@ export async function writeAuditLog(supabase: AuditInsertClient, entry: AuditLog action: entry.action, resource_type: entry.resourceType ?? null, resource_id: entry.resourceId ?? null, - metadata: entry.metadata ?? {}, + metadata: minimumAuditMetadata(entry), }); if (error) { logger.error("Audit log write failed", { action: entry.action, message: error.message }); diff --git a/supabase/migrations/20260717163000_minimize_audit_log_metadata.sql b/supabase/migrations/20260717163000_minimize_audit_log_metadata.sql new file mode 100644 index 000000000..716708afc --- /dev/null +++ b/supabase/migrations/20260717163000_minimize_audit_log_metadata.sql @@ -0,0 +1,33 @@ +-- Audit logs are retained indefinitely for clinical governance. Historical writes +-- included user-controlled document names/titles and content hashes in metadata, +-- which could preserve patient identifiers after the source document was renamed +-- or deleted. Keep the immutable event/resource record but remove free text and +-- retain only operational facts needed for audit triage. + +update public.audit_logs +set metadata = case action + when 'document_upload' then jsonb_strip_nulls(jsonb_build_object( + 'fileType', case when jsonb_typeof(metadata -> 'fileType') = 'string' then metadata -> 'fileType' end, + 'fileSize', case when jsonb_typeof(metadata -> 'fileSize') = 'number' then metadata -> 'fileSize' end + )) + when 'document_delete' then jsonb_strip_nulls(jsonb_build_object( + 'storageRemoved', case + when jsonb_typeof(metadata -> 'storageRemoved') in ('number', 'boolean') then metadata -> 'storageRemoved' + end + )) + else '{}'::jsonb +end +where metadata <> '{}'::jsonb; + +do $$ +declare + unsafe_metadata_count integer; +begin + select count(*) into unsafe_metadata_count + from public.audit_logs + where metadata ?| array['fileName', 'contentHash', 'previousTitle', 'newTitle', 'title']; + + if unsafe_metadata_count > 0 then + raise exception 'audit-log metadata minimization incomplete: % rows still contain user-controlled document text', unsafe_metadata_count; + end if; +end $$; diff --git a/tests/audit.test.ts b/tests/audit.test.ts index 2362fddb3..f52bc79b9 100644 --- a/tests/audit.test.ts +++ b/tests/audit.test.ts @@ -43,6 +43,72 @@ describe("writeAuditLog", () => { }); }); + it("drops user-controlled document text from indefinitely retained audit metadata", async () => { + const client = mockClient(async () => ({ error: null })); + + await writeAuditLog(client, { + ownerId: "owner-1", + action: "document_upload", + resourceId: "doc-1", + metadata: { + fileName: "Jane Doe MRN 123456.pdf", + contentHash: "content-derived-identifier", + fileType: "application/pdf", + fileSize: 1234, + nested: { title: "Patient Jane Doe" }, + }, + }); + + expect(client.insertSpy).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { fileType: "application/pdf", fileSize: 1234 } }), + ); + expect(JSON.stringify(client.insertSpy.mock.calls[0]?.[0])).not.toContain("Jane Doe"); + expect(JSON.stringify(client.insertSpy.mock.calls[0]?.[0])).not.toContain("content-derived-identifier"); + }); + + it("does not retain previous or replacement titles for rename events", async () => { + const client = mockClient(async () => ({ error: null })); + + await writeAuditLog(client, { + ownerId: "owner-1", + action: "document_rename", + resourceId: "doc-1", + metadata: { previousTitle: "Jane Doe", newTitle: "Patient Smith" }, + }); + + expect(client.insertSpy).toHaveBeenCalledWith(expect.objectContaining({ metadata: {} })); + }); + + it("retains numeric storageRemoved counts from document deletes", async () => { + const client = mockClient(async () => ({ error: null })); + + await writeAuditLog(client, { + ownerId: "owner-1", + action: "document_delete", + resourceId: "doc-1", + metadata: { + storageRemoved: 2, + title: "Jane Doe MRN 123456.pdf", + }, + }); + + expect(client.insertSpy).toHaveBeenCalledWith(expect.objectContaining({ metadata: { storageRemoved: 2 } })); + expect(JSON.stringify(client.insertSpy.mock.calls[0]?.[0])).not.toContain("Jane Doe"); + }); + + it("retains boolean storageRemoved flags from legacy delete callers", async () => { + const client = mockClient(async () => ({ error: null })); + + await writeAuditLog(client, { + ownerId: "owner-1", + action: "document_delete", + resourceId: "doc-1", + metadata: { storageRemoved: true }, + }); + + expect(client.insertSpy).toHaveBeenCalledWith(expect.objectContaining({ metadata: { storageRemoved: true } })); + }); + it("does not throw when the insert returns an error", async () => { const client = mockClient(async () => ({ error: { message: "insert failed" } })); await expect(writeAuditLog(client, { ownerId: "o", action: "document_rename" })).resolves.toBeUndefined(); diff --git a/tests/factsheets-data.test.ts b/tests/factsheets-data.test.ts new file mode 100644 index 000000000..15b49e417 --- /dev/null +++ b/tests/factsheets-data.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { factsheets, findFactsheet } from "@/components/factsheets/factsheets-data"; + +describe("factsheet sample records", () => { + it("only resolves the explicitly supplied sample factsheets", () => { + expect(findFactsheet(factsheets[0]!.slug)).toEqual(factsheets[0]!); + expect(findFactsheet("unknown-factsheet")).toBeUndefined(); + }); + + it("labels every record as sample content", () => { + expect(factsheets.every((factsheet) => factsheet.updated === "Sample content")).toBe(true); + }); +}); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 478c4e982..eca054788 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -228,6 +228,36 @@ test.describe("universal search typeahead", () => { expect(new URL((await universalRequest).url()).searchParams.get("domains")?.split(",")).not.toContain("services"); }); + test("places submitted cross-mode matches after the owning mode results", async ({ page }) => { + // "13YARN" matches the demo service fixture so service-search-results renders. + // Use an inline mock that echoes back the same query so universal-also-matches renders. + await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { + const url = new URL(route.request().url()); + const mode = url.searchParams.get("mode") ?? "services"; + const q = url.searchParams.get("q") ?? "13YARN"; + await fulfillUniversalSearch(route, { + ...universalPayload, + query: q, + contextMode: mode, + preferredDomains: [], + domainOrder: universalPayload.groups.map((g) => g.kind), + }); + }); + await page.goto("/services?q=13YARN&run=1", { waitUntil: "domcontentloaded" }); + + const results = page.getByTestId("service-search-results"); + const alsoMatches = page.getByTestId("universal-also-matches"); + await expect(results).toBeVisible(); + await expect(alsoMatches).toBeVisible(); + expect( + await alsoMatches.evaluate((node) => { + const resultNode = document.querySelector('[data-testid="service-search-results"]'); + return Boolean((resultNode?.compareDocumentPosition(node) ?? 0) & Node.DOCUMENT_POSITION_FOLLOWING); + }), + "universal-also-matches panel must appear after primary results in the DOM", + ).toBe(true); + }); + test("shows submitted cross-mode matches on phones outside hidden desktop headers", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await mockUniversalSearch(page);