From 196c331068e24c58abcfefaca8b773ba20d0c960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 09:40:12 +0200 Subject: [PATCH] fix(mcp): make the metric and lab deep links land on their pages MCP `search` and `fetch` link a metric as `/insights?metric=` and a lab value as `/labs?analyte=`. Neither page read its query, so every link opened the overview or the full list. `/insights` now resolves the id server-side, in whichever spelling the result carries (a Coach source key such as `hrv` or `bp`, a signal or metric-status key such as `GRIP_STRENGTH`, a measurement type, or a sub-page slug), and redirects to the metric's page, or to its value list when no page focuses it. An id it cannot place renders the overview as before. `/labs` asks for the newest reading stored under the exact analyte name and opens its marker's page; a name with no reading keeps the list and drops the query. --- .../__tests__/page-metric-deep-link.test.tsx | 44 ++++++++++++ src/app/insights/page.tsx | 17 ++++- src/app/labs/page.tsx | 30 +++++++- .../__tests__/metric-deep-link.test.ts | 68 +++++++++++++++++++ src/lib/insights/metric-deep-link.ts | 66 ++++++++++++++++++ .../labs/__tests__/analyte-deep-link.test.ts | 43 ++++++++++++ src/lib/labs/analyte-deep-link.ts | 26 +++++++ src/lib/query-keys/labs.ts | 7 ++ 8 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 src/app/insights/__tests__/page-metric-deep-link.test.tsx create mode 100644 src/lib/insights/__tests__/metric-deep-link.test.ts create mode 100644 src/lib/insights/metric-deep-link.ts create mode 100644 src/lib/labs/__tests__/analyte-deep-link.test.ts create mode 100644 src/lib/labs/analyte-deep-link.ts diff --git a/src/app/insights/__tests__/page-metric-deep-link.test.tsx b/src/app/insights/__tests__/page-metric-deep-link.test.tsx new file mode 100644 index 000000000..0a0e86216 --- /dev/null +++ b/src/app/insights/__tests__/page-metric-deep-link.test.tsx @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * MCP deep links (`/insights?metric=`) used to land on the overview, + * whatever the id. The RSC now redirects a placeable id to the metric's page + * and renders the overview for anything else. + */ +const redirect = vi.hoisted(() => + vi.fn((href: string) => { + throw new Error(`REDIRECT ${href}`); + }), +); +vi.mock("next/navigation", () => ({ redirect })); +vi.mock("@/lib/auth/acting-carrier", () => ({ + getUnswitchedSession: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/dashboard/snapshot-read", () => ({ + readDashboardSnapshotCached: vi.fn(), +})); +vi.mock("../page-client", () => ({ default: () => null })); + +import InsightsPage from "../page"; + +beforeEach(() => { + redirect.mockClear(); +}); + +describe("/insights?metric=", () => { + it("redirects a metric id to its page", async () => { + await expect( + InsightsPage({ searchParams: Promise.resolve({ metric: "hrv" }) }), + ).rejects.toThrow("REDIRECT /insights/hrv"); + }); + + it("renders the overview for an id it cannot place, or none", async () => { + await expect( + InsightsPage({ searchParams: Promise.resolve({ metric: "nonsense" }) }), + ).resolves.toBeTruthy(); + await expect( + InsightsPage({ searchParams: Promise.resolve({}) }), + ).resolves.toBeTruthy(); + expect(redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 880f4b2d7..b79fec77d 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -4,7 +4,10 @@ import { QueryClient, } from "@tanstack/react-query"; +import { redirect } from "next/navigation"; + import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; +import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link"; import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { queryKeys } from "@/lib/query-keys"; @@ -51,7 +54,19 @@ import InsightsPageClient from "./page-client"; * - Fail-soft: no session or a builder hiccup renders the page exactly as * before this wrapper existed — the client cells own the fetch. */ -export default async function InsightsPage() { +export default async function InsightsPage({ + searchParams, +}: { + searchParams?: Promise>; +} = {}) { + // MCP deep links (`/insights?metric=`) go to the metric's own page. An + // id that names no metric leaves the overview as it was. + const { metric } = (await searchParams) ?? {}; + const target = insightsHrefForMetric( + typeof metric === "string" ? metric : null, + ); + if (target) redirect(target); + // Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e // server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which // only see CLIENT fetches — keep governing what every prefetched page paints. diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 69ca8f41e..86faac75d 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, FolderOpen, @@ -38,6 +38,12 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import type { LabResultListResponse } from "@/components/labs/types"; +import { + analyteFromQuery, + labHrefForAnalyteReading, +} from "@/lib/labs/analyte-deep-link"; export default function LabsPage() { const { user, isAuthenticated, isLoading } = useAuth(); @@ -89,6 +95,26 @@ export default function LabsPage() { disabled: dialogOpen, }); + // MCP deep links (`/labs?analyte=`) open the marker's own page. The + // newest reading under that exact name says which marker it is; a name + // with no reading keeps the list and drops the query. + const searchParams = useSearchParams(); + const linkedAnalyte = analyteFromQuery(searchParams.get("analyte")); + const analyteLink = useQuery({ + queryKey: queryKeys.labAnalyteLink(linkedAnalyte ?? ""), + queryFn: () => + apiGet( + `/api/labs?analyte=${encodeURIComponent(linkedAnalyte ?? "")}&limit=1&sortDir=desc`, + ), + enabled: !!linkedAnalyte && isAuthenticated && enabled, + }); + useEffect(() => { + if (!linkedAnalyte || !analyteLink.isFetched) return; + router.replace( + labHrefForAnalyteReading(analyteLink.data?.results?.[0]) ?? "/labs", + ); + }, [linkedAnalyte, analyteLink.isFetched, analyteLink.data, router]); + useEffect(() => { if (isLoading) return; if (!isAuthenticated) { diff --git a/src/lib/insights/__tests__/metric-deep-link.test.ts b/src/lib/insights/__tests__/metric-deep-link.test.ts new file mode 100644 index 000000000..155b9f109 --- /dev/null +++ b/src/lib/insights/__tests__/metric-deep-link.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link"; +import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric"; +import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +/** + * The MCP `search` / `fetch` results link a metric as `/insights?metric=` + * in several spellings. Every one must land on the metric's own page; an id + * that names nothing leaves the overview alone. + */ +describe("insightsHrefForMetric", () => { + it("routes the Coach source keys the data inventory links carry", () => { + expect(insightsHrefForMetric("hrv")).toBe("/insights/hrv"); + expect(insightsHrefForMetric("resting_hr")).toBe("/insights/resting-pulse"); + expect(insightsHrefForMetric("bp")).toBe("/insights/blood-pressure"); + expect(insightsHrefForMetric("weight")).toBe("/insights/weight"); + expect(insightsHrefForMetric("glucose")).toBe("/insights/blood-glucose"); + expect(insightsHrefForMetric("mood")).toBe("/insights/mood"); + expect(insightsHrefForMetric("workouts")).toBe("/insights/workouts"); + }); + + it("routes signal keys and measurement types", () => { + expect(insightsHrefForMetric("GRIP_STRENGTH")).toBe( + "/insights/grip-strength", + ); + expect(insightsHrefForMetric("CARDIO_RECOVERY")).toBe( + "/insights/cardio-recovery", + ); + expect(insightsHrefForMetric("HRV_RMSSD")).toBe("/insights/hrv"); + }); + + it("falls back to the value list for a type no page focuses", () => { + expect(insightsHrefForMetric("DAY_STRAIN")).toBe( + "/insights/values/DAY_STRAIN", + ); + }); + + it("routes a sub-page slug in either separator", () => { + expect(insightsHrefForMetric("walking-speed")).toBe( + "/insights/walking-speed", + ); + expect(insightsHrefForMetric("walking_speed")).toBe( + "/insights/walking-speed", + ); + }); + + it("leaves the overview for an id it cannot place", () => { + for (const id of [null, "", " ", "nonsense", "../admin", "a".repeat(80)]) { + expect(insightsHrefForMetric(id)).toBeNull(); + } + }); + + it("only ever points at a page that exists", () => { + const ids = [ + ...SUB_PAGE_SLUGS, + ...Object.keys(COACH_SOURCE_MEASUREMENT_TYPES), + ]; + for (const id of ids) { + const href = insightsHrefForMetric(id); + if (!href || href.startsWith("/insights/values/")) continue; + const dir = join(process.cwd(), "src/app", href); + expect(existsSync(join(dir, "page.tsx")), `${id} → ${href}`).toBe(true); + } + }); +}); diff --git a/src/lib/insights/metric-deep-link.ts b/src/lib/insights/metric-deep-link.ts new file mode 100644 index 000000000..544525c7a --- /dev/null +++ b/src/lib/insights/metric-deep-link.ts @@ -0,0 +1,66 @@ +/** + * Where `/insights?metric=` goes (MCP deep links). + * + * The MCP `search` and `fetch` results link a metric as + * `/insights?metric=`, with the id in whichever spelling that result + * carries: a Coach source key (`hrv`, `resting_hr`, `bp`), a signal or + * metric-status key (`GRIP_STRENGTH`, `CARDIO_RECOVERY`), or a sub-page slug. + * The overview read none of them, so every such link landed on the overview. + * This resolves the id to the metric's own page, or to its value list when no + * page focuses it, and to nothing for an id it cannot place (the overview + * then renders as before). Pure: no I/O. + */ +import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types"; +import type { CoachScopeSource } from "@/lib/ai/coach/types"; +import { + SUB_PAGE_SLUGS, + subPageSlugForType, + type SubPageSlug, +} from "@/lib/insights/sub-page-metric"; +import { SIGNALS } from "@/lib/signals/registry"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; + +/** Coach sources that read a model other than `Measurement`. */ +const NON_MEASUREMENT_SOURCES: Readonly> = { + mood: "/insights/mood", + workouts: "/insights/workouts", + compliance: "/insights/medications", +}; + +const SLUGS: ReadonlySet = new Set(SUB_PAGE_SLUGS); + +function hrefForType(type: string): string { + const slug = subPageSlugForType(type); + return slug + ? `/insights/${slug}` + : `/insights/values/${encodeURIComponent(type)}`; +} + +export function insightsHrefForMetric( + raw: string | null | undefined, +): string | null { + const id = raw?.trim() ?? ""; + if (id === "" || id.length > 64 || !/^[A-Za-z0-9_-]+$/.test(id)) { + return null; + } + const lower = id.toLowerCase(); + + // A sub-page slug, in either separator. + const slug = lower.replace(/_/g, "-"); + if (SLUGS.has(slug)) return `/insights/${slug as SubPageSlug}`; + + // A Coach source key, the spelling the data inventory links carry. + if (lower in NON_MEASUREMENT_SOURCES) return NON_MEASUREMENT_SOURCES[lower]!; + const sourceTypes = + COACH_SOURCE_MEASUREMENT_TYPES[lower as CoachScopeSource] ?? []; + if (sourceTypes.length > 0) return hrefForType(sourceTypes[0]!); + + // A signal key, then a measurement type itself. + const upper = id.toUpperCase(); + const signal = SIGNALS[upper]; + if (signal && (signal.kind === "measurement" || signal.kind === "score")) { + return hrefForType(signal.source.measurementType); + } + if (measurementTypeEnum.safeParse(upper).success) return hrefForType(upper); + return null; +} diff --git a/src/lib/labs/__tests__/analyte-deep-link.test.ts b/src/lib/labs/__tests__/analyte-deep-link.test.ts new file mode 100644 index 000000000..524570e5b --- /dev/null +++ b/src/lib/labs/__tests__/analyte-deep-link.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { + analyteFromQuery, + labHrefForAnalyteReading, +} from "@/lib/labs/analyte-deep-link"; +import { queryKeys } from "@/lib/query-keys"; + +/** + * `/labs?analyte=` (MCP deep links) opens the marker page of the newest + * reading stored under that name, and keeps the list otherwise. + */ +describe("labs analyte deep link", () => { + it("follows a named analyte and ignores an empty or oversized one", () => { + expect(analyteFromQuery(" Ferritin ")).toBe("Ferritin"); + expect(analyteFromQuery(null)).toBeNull(); + expect(analyteFromQuery(" ")).toBeNull(); + expect(analyteFromQuery("x".repeat(121))).toBeNull(); + }); + + it("opens the marker page of the reading, and nothing without one", () => { + expect(labHrefForAnalyteReading({ biomarkerId: "bm_1" })).toBe( + "/labs/bm_1", + ); + expect(labHrefForAnalyteReading({ biomarkerId: null })).toBeNull(); + expect(labHrefForAnalyteReading(undefined)).toBeNull(); + }); + + it("keeps its read under the lab-results prefix, apart from the list", () => { + const key = queryKeys.labAnalyteLink("Ferritin"); + expect(key[0]).toBe("lab-results"); + expect(key).not.toEqual( + queryKeys.labResultsList({ + analyte: "Ferritin", + panel: undefined, + from: undefined, + to: undefined, + page: 0, + sortDir: "desc", + }), + ); + }); +}); diff --git a/src/lib/labs/analyte-deep-link.ts b/src/lib/labs/analyte-deep-link.ts new file mode 100644 index 000000000..0a8b47531 --- /dev/null +++ b/src/lib/labs/analyte-deep-link.ts @@ -0,0 +1,26 @@ +/** + * Where `/labs?analyte=` goes (MCP deep links). + * + * MCP `search` and `fetch` link a lab value as `/labs?analyte=`, the + * analyte name exactly as stored on the reading. The labs page read nothing + * from the query, so every such link showed the whole list. The page now asks + * for the newest reading under that name and opens its marker's page; a name + * with no reading, or one whose reading carries no marker, keeps the list. + */ + +/** The analyte named in the query, or null when there is none to follow. */ +export function analyteFromQuery( + raw: string | null | undefined, +): string | null { + const analyte = raw?.trim() ?? ""; + return analyte.length > 0 && analyte.length <= 120 ? analyte : null; +} + +/** The marker page for the newest reading under that name, or null. */ +export function labHrefForAnalyteReading( + reading: { biomarkerId: string | null } | undefined, +): string | null { + return reading?.biomarkerId + ? `/labs/${encodeURIComponent(reading.biomarkerId)}` + : null; +} diff --git a/src/lib/query-keys/labs.ts b/src/lib/query-keys/labs.ts index cbb3f7031..45cdf949a 100644 --- a/src/lib/query-keys/labs.ts +++ b/src/lib/query-keys/labs.ts @@ -29,6 +29,13 @@ export const labKeys = { params.sortDir, ] as const, + // `/labs?analyte=` (MCP deep links): the newest reading stored under + // that exact analyte name, one row, to find its marker's page. Its own key + // because the page size differs from every list read; the `lab-results` + // prefix lets a result mutation evict it too. + labAnalyteLink: (analyte: string) => + ["lab-results", "analyte-link", analyte] as const, + // The visit form's lab picker: newest results, its own page size. Its own // key because the Labs list reads a different page size, and a shared key // would serve one read's page to the other.