Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
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";
Comment thread
cursor[bot] marked this conversation as resolved.
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="flex w-full justify-center">
{hasSelection ? (
<Link
href={routeWithQuery("/differentials/presentations", query, selectedIds)}
href={differentialSelectedCompareHref(query, selectedIds)}
data-testid="differentials-compare-selected-mobile"
className="inline-flex min-h-12 max-w-full items-center gap-2.5 rounded-full border border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] py-1 pl-4 pr-2.5 text-sm font-extrabold text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-elevated)] transition active:bg-[color:var(--clinical-accent-hover)]"
>
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 @@ -929,14 +919,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 @@ -1097,7 +1087,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 @@ -1106,7 +1096,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 @@ -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);
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
51 changes: 51 additions & 0 deletions tests/differentials-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Comment thread
cursor[bot] marked this conversation as resolved.

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"]);
});
});
Loading