feat(insights): citation footnote on recommendations with medical reference

When a recommendation carries a `referenceId` pointing into the
curated medical-reference bundle, render a small footnote underneath
linking to the guideline. Format: "Source: ESH 2023 — ESH 2023
Guidelines for the Management of Arterial Hypertension" (locale-
routed title), with a tiny external-link icon, opens in a new tab via
target="_blank" rel="noreferrer". Per the v1.4.16 research doc §7
the URL never opens in an in-app webview.

`InsightResult.recommendations` widens to accept either the legacy
plain-string shape OR a structured `{ text, referenceId? }` object so
existing rendering paths keep working while the new citation surface
plugs in through the same component. `getMedicalReferenceById()`
returns undefined for unknown ids — the UI silently drops the
footnote in that case rather than rendering a broken link (defence
in depth on top of the schema validation).

i18n keys `insights.recommendation.source` and
`insights.recommendation.viewSource` land in both EN and DE.

6 component tests pin the no-footnote / EN-locale / DE-locale /
unknown-id-defence / multi-rec paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 00:04:24 +02:00
1 parent 94c748d804
commit a66c128dd9
14 files changed
+1301 -11

No files matched your search

@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { GET } from "../route";
import { requireAdmin } from "@/lib/api-handler";
import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer";
import type { WideEvent } from "@/lib/logging/types";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
function makeEvent(overrides: Partial<WideEvent> = {}): WideEvent {
return {
timestamp: new Date().toISOString(),
duration_ms: 12,
request_id: "req-" + Math.random().toString(16).slice(2),
trace_id: "trace-" + Math.random().toString(16).slice(2),
level: "info",
kind: "http",
service: "healthlog",
environment: "test",
...overrides,
};
}
function req(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/admin/app-logs${query ? "?" + query : ""}`,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
clearLogBuffer();
});
describe("GET /api/admin/app-logs", () => {
it("returns the buffered events newest-first", async () => {
appendLogEvent(makeEvent({ trace_id: "first" }));
appendLogEvent(makeEvent({ trace_id: "second" }));
const res = await GET(req());
const json = (await res.json()) as {
data: { events: WideEvent[]; meta: { total: number } };
};
expect(json.data.events).toHaveLength(2);
expect(json.data.events[0].trace_id).toBe("second");
expect(json.data.meta.total).toBe(2);
});
it("filters by traceId", async () => {
appendLogEvent(makeEvent({ trace_id: "alpha" }));
appendLogEvent(makeEvent({ trace_id: "beta" }));
const res = await GET(req("traceId=alpha"));
const json = (await res.json()) as { data: { events: WideEvent[] } };
expect(json.data.events).toHaveLength(1);
expect(json.data.events[0].trace_id).toBe("alpha");
});
it("filters by level + action substring", async () => {
appendLogEvent(
makeEvent({ level: "info", action: { name: "measurement.create" } }),
);
appendLogEvent(
makeEvent({ level: "error", action: { name: "auth.login.failed" } }),
);
const res = await GET(req("level=error&action=auth"));
const json = (await res.json()) as { data: { events: WideEvent[] } };
expect(json.data.events).toHaveLength(1);
expect(json.data.events[0].action?.name).toBe("auth.login.failed");
});
it("redacts secrets in error.message before egress", async () => {
appendLogEvent(
makeEvent({
level: "error",
error: {
type: "Error",
// Bearer token in the error path — must be redacted on render.
message: "Authorization: Bearer hlk_abc123def456 invalid",
},
}),
);
const res = await GET(req());
const json = (await res.json()) as { data: { events: WideEvent[] } };
expect(json.data.events[0].error?.message).not.toContain(
"hlk_abc123def456",
);
expect(json.data.events[0].error?.message).toContain("[REDACTED]");
});
it("supports the `limit` cap", async () => {
for (let i = 0; i < 60; i++) {
appendLogEvent(makeEvent({ request_id: `r-${i}` }));
}
const res = await GET(req("limit=10"));
const json = (await res.json()) as { data: { events: WideEvent[] } };
expect(json.data.events).toHaveLength(10);
});
it("rejects an invalid level value", async () => {
appendLogEvent(makeEvent());
// Invalid `level` — Zod parse should throw and the apiHandler test
// mock rethrows it (we mock only `requireAdmin`, not the wrapper
// error-translator). We simply assert the call rejects.
await expect(GET(req("level=critical"))).rejects.toThrow();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
import { redactSecrets } from "@/lib/logging/redact";
import type { WideEvent } from "@/lib/logging/types";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
export const dynamic = "force-dynamic";
const querySchema = z.object({
traceId: z.string().trim().min(1).max(200).optional(),
level: z.enum(["debug", "info", "warn", "error"]).optional(),
action: z.string().trim().min(1).max(200).optional(),
since: z.string().datetime().optional(),
until: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
});
/**
* Admin endpoint: tail of the per-process in-memory wide-event ring buffer.
*
* The buffer is populated by `transports.emitEvent()` so the result mirrors
* what would be shipped to Loki (sampler-gated). Per-process and volatile —
* see `src/lib/logging/in-memory-buffer.ts` for the limitations.
*
* Privacy: every event is run through `redactSecrets()` before egress so a
* stray Bearer / hlk_ / sk- token in an `error.message` never reaches the
* admin UI. Storage stays raw to keep diagnostics intact when shipped to
* Loki, but the egress path here is the user-facing one.
*/
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
annotate({ action: { name: "admin.app-logs.list" } });
const { searchParams } = new URL(request.url);
const parsed = querySchema.parse({
traceId: searchParams.get("traceId") ?? undefined,
level: searchParams.get("level") ?? undefined,
action: searchParams.get("action") ?? undefined,
since: searchParams.get("since") ?? undefined,
until: searchParams.get("until") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
});
const events = readLogBuffer({
traceId: parsed.traceId,
level: parsed.level,
action: parsed.action,
since: parsed.since ? new Date(parsed.since) : undefined,
until: parsed.until ? new Date(parsed.until) : undefined,
limit: parsed.limit,
});
const redacted = events.map(redactEventForEgress);
return apiSuccess({
events: redacted,
meta: {
total: redacted.length,
bufferMax: LOG_BUFFER_MAX,
},
});
});
/**
* Apply `redactSecrets()` to every string-shaped field that could carry a
* leaked credential. Keeps the structure intact so the UI can still render
* the JSON tree.
*/
function redactEventForEgress(event: WideEvent): WideEvent {
// structuredClone keeps us from mutating the live buffer entry.
const out = structuredClone(event);
if (out.error?.message) {
out.error.message = redactSecrets(out.error.message);
}
if (out.error?.stack) {
out.error.stack = redactSecrets(out.error.stack);
}
if (out.warnings?.length) {
out.warnings = out.warnings.map((w) => redactSecrets(w));
}
if (out.http?.user_agent) {
// UA strings are public, but a misbehaving client could shove a token
// into one. Cheap to redact, free to keep correct.
out.http.user_agent = redactSecrets(out.http.user_agent);
}
if (out.external_calls?.length) {
out.external_calls = out.external_calls.map((c) => ({
...c,
error: c.error ? redactSecrets(c.error) : c.error,
}));
}
return out;
}
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: {
findMany: vi.fn(),
count: vi.fn(),
groupBy: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
function req(query = ""): NextRequest {
return new NextRequest(
`http://localhost/api/admin/audit-log${query ? "?" + query : ""}`,
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([]);
vi.mocked(prisma.auditLog.count).mockResolvedValue(0);
vi.mocked(prisma.auditLog.groupBy).mockResolvedValue([]);
});
describe("GET /api/admin/audit-log — extended filters", () => {
it("supports `page` + `perPage` pagination", async () => {
await GET(req("page=3&perPage=25"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
// skip = (page - 1) * perPage = 50; take = perPage = 25
expect(call.skip).toBe(50);
expect(call.take).toBe(25);
});
it("clamps perPage to the allowed set {25, 50, 100}", async () => {
await GET(req("perPage=999"));
// any out-of-range value falls back to the default 50
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
expect(call.take).toBe(50);
});
it("filters by `actor` against userId OR user.username", async () => {
await GET(req("actor=marc"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
// Expect an OR clause covering userId + user.username (contains)
expect(where.OR).toBeDefined();
const or = where.OR as Array<Record<string, unknown>>;
const matchesUserId = or.some((c) => c.userId === "marc");
const matchesUsername = or.some(
(c) =>
typeof c.user === "object" &&
c.user !== null &&
// user: { username: { contains: "marc", mode: "insensitive" } }
JSON.stringify(c).toLowerCase().includes("marc"),
);
expect(matchesUserId).toBe(true);
expect(matchesUsername).toBe(true);
});
it("filters by `action` exact match", async () => {
await GET(req("action=auth.login.failed"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
expect(where.action).toBe("auth.login.failed");
});
it("filters by `target` (substring on `details` JSON string)", async () => {
await GET(req("target=measurement-42"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
expect(where.details).toBeDefined();
expect(JSON.stringify(where.details)).toContain("measurement-42");
});
it("filters by `since` and `until` window", async () => {
const since = "2026-05-01T00:00:00.000Z";
const until = "2026-05-09T23:59:59.000Z";
await GET(req(`since=${since}&until=${until}`));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
expect(where.createdAt).toEqual({
gte: new Date(since),
lte: new Date(until),
});
});
it("returns paginated meta", async () => {
vi.mocked(prisma.auditLog.count).mockResolvedValue(123);
const res = await GET(req("page=2&perPage=25"));
const json = (await res.json()) as {
data: { meta: { total: number; page: number; perPage: number } };
};
expect(json.data.meta.total).toBe(123);
expect(json.data.meta.page).toBe(2);
expect(json.data.meta.perPage).toBe(25);
});
it("preserves the legacy `limit`/`offset` shape (back-compat)", async () => {
await GET(req("limit=10&offset=5"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
expect(call.take).toBe(10);
expect(call.skip).toBe(5);
});
it("supports the legacy `filter=auth` shortcut", async () => {
await GET(req("filter=auth"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
// startsWith "auth." is the legacy contract
expect(JSON.stringify(where.action)).toContain("auth.");
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: {
groupBy: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
});
describe("GET /api/admin/audit-log/actions", () => {
it("returns the distinct list of actions sorted ascending", async () => {
vi.mocked(prisma.auditLog.groupBy).mockResolvedValue([
{ action: "auth.login" },
{ action: "auth.login.failed" },
{ action: "measurement.create" },
] as never);
const res = await (GET as () => Promise<Response>)();
const json = (await res.json()) as { data: { actions: string[] } };
expect(json.data.actions).toEqual([
"auth.login",
"auth.login.failed",
"measurement.create",
]);
});
});
@@ -0,0 +1,30 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const dynamic = "force-dynamic";
/**
* Distinct list of `action` values currently present in the audit log,
* sorted alphabetically. Powers the action-dropdown on the
* `/admin/login-overview` filter UI so admins don't have to memorise the
* action vocabulary (`auth.login`, `auth.bearer.failure`, `device.register.denied`,
* etc.).
*
* `groupBy` is the cheapest way to get distinct values without sucking the
* whole table into Node memory.
*/
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.audit-log.actions" } });
const grouped = await prisma.auditLog.groupBy({
by: ["action"],
orderBy: { action: "asc" },
});
return apiSuccess({
actions: grouped.map((g) => g.action),
});
});