Skip to content
Merged
26 changes: 18 additions & 8 deletions src/app/differentials/presentations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
28 changes: 9 additions & 19 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -111,17 +112,6 @@ const candidateIconBySlug: Array<[string, LucideIcon]> = [
["delirium", BrainCircuit],
];

function routeWithQuery(path: string, query: string, selectedIds?: Set<string>) {
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
Expand Down Expand Up @@ -162,7 +152,7 @@ function DifferentialsMobileCompareBar({
<div aria-live="polite" className="differentials-mobile-compare-fab">
{hasSelection ? (
<Link
href={routeWithQuery("/differentials/presentations", query, selectedIds)}
href={differentialSelectedCompareHref(query, selectedIds)}
data-testid="differentials-compare-selected-mobile"
className="differentials-mobile-compare-fab__button"
>
Expand Down Expand Up @@ -599,7 +589,7 @@ function SafetyCard({ safety, query }: { safety: string; query: string }) {
</h2>
<p className="mt-2 text-sm font-semibold leading-6 text-[color:var(--text-heading)]">{safety}</p>
<Link
href={routeWithQuery("/differentials/presentations", query)}
href={differentialRouteWithQuery("/differentials/presentations", query)}
className="mt-2 inline-flex min-h-tap items-center gap-1.5 text-sm font-bold text-[color:var(--clinical-accent)]"
>
View presentation guide
Expand Down Expand Up @@ -933,14 +923,14 @@ function SearchResultsView({
</p>
<div className="flex flex-wrap gap-2">
<Link
href={routeWithQuery("/differentials/presentations", query)}
href={differentialRouteWithQuery("/differentials/presentations", query)}
className="inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-sm font-extrabold text-[color:var(--clinical-accent)]"
>
Browse presentations
<ChevronRight className="h-4 w-4" aria-hidden />
</Link>
<Link
href={routeWithQuery("/differentials/diagnoses", query)}
href={differentialRouteWithQuery("/differentials/diagnoses", query)}
className="inline-flex min-h-tap items-center gap-1.5 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-3 text-sm font-extrabold text-[color:var(--clinical-accent)]"
>
Browse diagnoses
Expand Down Expand Up @@ -1101,7 +1091,7 @@ function SearchResultsView({
</div>

<Link
href={routeWithQuery("/differentials/diagnoses", query)}
href={differentialRouteWithQuery("/differentials/diagnoses", query)}
className="hidden min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-4 text-sm font-extrabold text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent-border)] lg:inline-flex"
>
View all catalogue matches ({results.length})
Expand All @@ -1110,7 +1100,7 @@ function SearchResultsView({

{selectedCount > 0 ? (
<Link
href={routeWithQuery("/differentials/presentations", query, comparisonIds)}
href={differentialSelectedCompareHref(query, comparisonIds)}
className="hidden min-h-14 w-full items-center justify-center gap-3 rounded-lg bg-[color:var(--clinical-accent)] px-4 text-base font-extrabold text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-elevated)] transition hover:bg-[color:var(--clinical-accent-hover)] lg:inline-flex"
>
<GitCompareArrows className="h-5 w-5" aria-hidden />
Expand Down Expand Up @@ -1209,12 +1199,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);
Expand Down
24 changes: 24 additions & 0 deletions src/lib/differentials-navigation.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
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<string>) {
return differentialRouteWithQuery("/differentials/presentations", query, selectedIds);
}
2 changes: 1 addition & 1 deletion tests/audit-navigation-auth-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
46 changes: 46 additions & 0 deletions tests/differentials-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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).toContain("ids=");
expect(location).not.toContain("0.0.0.0");
});
});