diff --git a/src/app/differentials/presentations/route.ts b/src/app/differentials/presentations/route.ts index 001912f0f..282b0555c 100644 --- a/src/app/differentials/presentations/route.ts +++ b/src/app/differentials/presentations/route.ts @@ -2,20 +2,30 @@ import { type NextRequest, NextResponse } from "next/server"; import { getPresentationWorkflowSelectionForDiagnosisIds } from "@/lib/differentials"; -export function GET(request: NextRequest) { +function presentationsRedirectLocation(request: NextRequest) { const query = (request.nextUrl.searchParams.get("query") ?? request.nextUrl.searchParams.get("q"))?.trim(); const selectedIds = (request.nextUrl.searchParams.get("ids") ?? "") .split(",") .map((id) => id.trim()) .filter(Boolean); const selection = getPresentationWorkflowSelectionForDiagnosisIds(selectedIds); - const destination = new URL( - `/differentials/presentations/${selection?.workflow.id ?? "acute-confusion-encephalopathy"}`, - request.url, - ); - if (query) destination.searchParams.set("q", query); - if (selection?.diagnosisIds.length) destination.searchParams.set("ids", selection.diagnosisIds.join(",")); - return NextResponse.redirect(destination); + const params = new URLSearchParams(); + if (query) params.set("q", query); + if (selection?.diagnosisIds.length) params.set("ids", selection.diagnosisIds.join(",")); + const pathname = `/differentials/presentations/${selection?.workflow.id ?? "acute-confusion-encephalopathy"}`; + const suffix = params.toString(); + // Relative Location so redirects stay same-origin in the browser even when + // the server request URL uses a bind address like 0.0.0.0. + return suffix ? `${pathname}?${suffix}` : pathname; +} + +export function GET(request: NextRequest) { + return new NextResponse(null, { + status: 307, + headers: { + Location: presentationsRedirectLocation(request), + }, + }); } export const HEAD = GET; diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index dcc9caa09..3042f07ba 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -30,6 +30,7 @@ import { useDifferentialSearch } from "@/components/clinical-dashboard/use-diffe import { useResultSort } from "@/components/use-result-sort"; import { cn } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; +import { differentialRouteWithQuery, differentialSelectedCompareHref } from "@/lib/differentials-navigation"; import { differentialsMobileCompareAddonSlotId } from "@/lib/mode-home-composer"; import { composeDifferentialSearchResults, @@ -111,17 +112,6 @@ const candidateIconBySlug: Array<[string, LucideIcon]> = [ ["delirium", BrainCircuit], ]; -function routeWithQuery(path: string, query: string, selectedIds?: Set) { - const params = new URLSearchParams(); - const trimmedQuery = query.trim(); - if (trimmedQuery) params.set("q", trimmedQuery); - if (selectedIds && selectedIds.size > 0) { - params.set("ids", Array.from(selectedIds).join(",")); - } - const suffix = params.toString(); - return suffix ? `${path}?${suffix}` : path; -} - /** * Mobile/tablet floating compare action. Portals into the search composer's * addon slot so it stays anchored beside the active result controls, but @@ -162,7 +152,7 @@ function DifferentialsMobileCompareBar({
{hasSelection ? ( @@ -599,7 +589,7 @@ function SafetyCard({ safety, query }: { safety: string; query: string }) {

{safety}

View presentation guide @@ -929,14 +919,14 @@ function SearchResultsView({

Browse presentations Browse diagnoses @@ -1097,7 +1087,7 @@ function SearchResultsView({
View all catalogue matches ({results.length}) @@ -1106,7 +1096,7 @@ function SearchResultsView({ {selectedCount > 0 ? ( @@ -1205,12 +1195,12 @@ export function DifferentialsHome({ function handleAction(action: DifferentialAction) { if (action.target === "presentations") { if (onOpenPresentations) onOpenPresentations(action.query); - else router.push(routeWithQuery("/differentials/presentations", action.query)); + else router.push(differentialRouteWithQuery("/differentials/presentations", action.query)); return; } if (action.target === "diagnoses") { if (onOpenDiagnoses) onOpenDiagnoses(action.query); - else router.push(routeWithQuery("/differentials/diagnoses", action.query)); + else router.push(differentialRouteWithQuery("/differentials/diagnoses", action.query)); return; } runSearch(action.query); diff --git a/src/lib/differentials-navigation.ts b/src/lib/differentials-navigation.ts new file mode 100644 index 000000000..592bc99e7 --- /dev/null +++ b/src/lib/differentials-navigation.ts @@ -0,0 +1,24 @@ +/** + * Client-safe differentials href helpers. + * Keep this module free of `@/lib/differentials` / snapshot imports so the + * clinical-dashboard client bundle stays fixture-free. + */ + +export function differentialRouteWithQuery(path: string, query: string, selectedIds?: Iterable) { + const params = new URLSearchParams(); + const trimmedQuery = query.trim(); + if (trimmedQuery) params.set("q", trimmedQuery); + const ids = selectedIds ? Array.from(selectedIds, (id) => id.trim()).filter(Boolean) : []; + if (ids.length > 0) params.set("ids", ids.join(",")); + const suffix = params.toString(); + return suffix ? `${path}?${suffix}` : path; +} + +/** + * Compare-selected CTA href. Resolves the presentation workflow on the server + * via `/differentials/presentations` (see presentations/route.ts) so the client + * never loads the differentials snapshot just to build a link. + */ +export function differentialSelectedCompareHref(query: string, selectedIds: Iterable) { + return differentialRouteWithQuery("/differentials/presentations", query, selectedIds); +} diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 9e2bbb840..813cc4f59 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -40,7 +40,7 @@ describe("audit navigation and auth regressions", () => { ); expect(presentations.status).toBe(307); expect(presentations.headers.get("location")).toBe( - "https://clinical-kb.test/differentials/presentations/acute-confusion-encephalopathy?q=acute+confusion&ids=delirium", + "/differentials/presentations/acute-confusion-encephalopathy?q=acute+confusion&ids=delirium", ); const medications = redirectMedications(new NextRequest("https://clinical-kb.test/medications?ignored=1")); diff --git a/tests/differentials-navigation.test.ts b/tests/differentials-navigation.test.ts new file mode 100644 index 000000000..b52c60a32 --- /dev/null +++ b/tests/differentials-navigation.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; + +import { GET as redirectPresentations } from "@/app/differentials/presentations/route"; +import { differentialRouteWithQuery, differentialSelectedCompareHref } from "@/lib/differentials-navigation"; + +describe("differentials navigation", () => { + it("builds same-origin relative query routes", () => { + expect(differentialRouteWithQuery("/differentials/diagnoses", " acute confusion ")).toBe( + "/differentials/diagnoses?q=acute+confusion", + ); + }); + + it("keeps compare-selected hrefs client-safe and ID-preserving without resolving the workflow locally", () => { + const href = differentialSelectedCompareHref( + "Pain", + new Set(["anorexia-nervosa", "bulimia-nervosa-binge-purge-pattern"]), + ); + + expect(href).toBe("/differentials/presentations?q=Pain&ids=anorexia-nervosa%2Cbulimia-nervosa-binge-purge-pattern"); + expect(href).not.toContain("0.0.0.0"); + expect(href).not.toMatch(/^https?:\/\//); + }); + + it("does not import the differentials snapshot module from the client-safe navigation helpers", async () => { + const { readFileSync } = await import("node:fs"); + const source = readFileSync(new URL("../src/lib/differentials-navigation.ts", import.meta.url), "utf8"); + expect(source).not.toMatch(/from\s+["']@\/lib\/differentials["']/); + expect(source).not.toMatch(/differentials-snapshot|loadDifferentialSnapshot/); + }); + + it("redirects compare selection with a relative Location even when the request host is a bind address", () => { + const response = redirectPresentations( + new NextRequest( + "http://0.0.0.0:4461/differentials/presentations?q=Pain&ids=anorexia-nervosa,bulimia-nervosa-binge-purge-pattern", + ), + ); + + expect(response.status).toBe(307); + const location = response.headers.get("location"); + expect(location).toMatch(/^\/differentials\/presentations\/[^/?]+/); + expect(location).toContain("q=Pain"); + expect(location).not.toContain("0.0.0.0"); + + const parsedUrl = new URL(location!, "http://placeholder.invalid"); + const idsParam = parsedUrl.searchParams.get("ids"); + expect(idsParam).toBeTruthy(); + const parsedIds = idsParam!.split(","); + expect(parsedIds).toEqual(["anorexia-nervosa", "bulimia-nervosa-binge-purge-pattern"]); + }); +});