diff --git a/messages/de.json b/messages/de.json index 92ecbd99d..d61cb1326 100644 --- a/messages/de.json +++ b/messages/de.json @@ -658,6 +658,10 @@ "heroFindingWarning": "Wichtigster Alarm", "correlationsTitle": "Zusammenhänge", "recommendationsTitle": "Empfehlungen", + "recommendation": { + "source": "Quelle", + "viewSource": "Leitlinie in neuem Tab öffnen" + }, "dataFoundation": "Datengrundlage", "dataGaps": "Datenlücken:", "lastUpdated": "Zuletzt aktualisiert", diff --git a/messages/en.json b/messages/en.json index 74a60313c..9d0fa551d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -658,6 +658,10 @@ "heroFindingWarning": "Top alert", "correlationsTitle": "Correlations", "recommendationsTitle": "Recommendations", + "recommendation": { + "source": "Source", + "viewSource": "Open guideline in a new tab" + }, "dataFoundation": "Data coverage", "dataGaps": "Data gaps:", "lastUpdated": "Last updated", diff --git a/src/app/api/admin/app-logs/__tests__/route.test.ts b/src/app/api/admin/app-logs/__tests__/route.test.ts new file mode 100644 index 000000000..dabd2ac88 --- /dev/null +++ b/src/app/api/admin/app-logs/__tests__/route.test.ts @@ -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( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + 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 { + 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(); + }); +}); diff --git a/src/app/api/admin/app-logs/route.ts b/src/app/api/admin/app-logs/route.ts new file mode 100644 index 000000000..82bcb783b --- /dev/null +++ b/src/app/api/admin/app-logs/route.ts @@ -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; +} diff --git a/src/app/api/admin/audit-log/__tests__/route.test.ts b/src/app/api/admin/audit-log/__tests__/route.test.ts new file mode 100644 index 000000000..14ad22f0e --- /dev/null +++ b/src/app/api/admin/audit-log/__tests__/route.test.ts @@ -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( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + 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; + // Expect an OR clause covering userId + user.username (contains) + expect(where.OR).toBeDefined(); + const or = where.OR as Array>; + 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; + 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; + 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; + 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; + // startsWith "auth." is the legacy contract + expect(JSON.stringify(where.action)).toContain("auth."); + }); +}); diff --git a/src/app/api/admin/audit-log/actions/__tests__/route.test.ts b/src/app/api/admin/audit-log/actions/__tests__/route.test.ts new file mode 100644 index 000000000..9e0ed356e --- /dev/null +++ b/src/app/api/admin/audit-log/actions/__tests__/route.test.ts @@ -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( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + 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)(); + const json = (await res.json()) as { data: { actions: string[] } }; + expect(json.data.actions).toEqual([ + "auth.login", + "auth.login.failed", + "measurement.create", + ]); + }); +}); diff --git a/src/app/api/admin/audit-log/actions/route.ts b/src/app/api/admin/audit-log/actions/route.ts new file mode 100644 index 000000000..6cac89f71 --- /dev/null +++ b/src/app/api/admin/audit-log/actions/route.ts @@ -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), + }); +}); diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx new file mode 100644 index 000000000..509a0f523 --- /dev/null +++ b/src/components/admin/app-log-preview-section.tsx @@ -0,0 +1,301 @@ +"use client"; + +/** + * `` — surfaces the in-process wide-event ring buffer + * (`src/lib/logging/in-memory-buffer.ts`) so admins can drill into the most + * recent ~500 structured events without standing up a Loki stack just to + * read the last hour. + * + * Limitations (covered by the section header): + * - Per-process. Under split web/worker deployments, only the web buffer + * is visible here. Worker logs ship to Loki when configured. + * - Volatile. Restart drops the buffer. + * + * Privacy: every event passes through `redactSecrets()` server-side before + * reaching this component; storage stays raw so the diagnostic value + * survives shipping to Loki. + */ + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + AlertCircle, + AlertTriangle, + CheckCircle2, + FileText, + Info, + Loader2, + RefreshCw, + X, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import type { WideEvent, LogLevel } from "@/lib/logging/types"; + +type RangePreset = "15m" | "1h" | "6h" | "all"; + +interface AppLogsResponse { + events: WideEvent[]; + meta: { + total: number; + bufferMax: number; + }; +} + +function rangeToSince(range: RangePreset): string | undefined { + if (range === "all") return undefined; + const now = Date.now(); + const minutes = range === "15m" ? 15 : range === "1h" ? 60 : 6 * 60; + return new Date(now - minutes * 60 * 1000).toISOString(); +} + +function levelIcon(level: LogLevel) { + switch (level) { + case "error": + return ; + case "warn": + return ; + case "debug": + return ; + case "info": + default: + return ; + } +} + +export function AppLogPreviewSection() { + const { t } = useTranslations(); + + const [traceId, setTraceId] = useState(""); + const [actionFilter, setActionFilter] = useState(""); + const [level, setLevel] = useState("__all__"); + const [range, setRange] = useState("1h"); + const [selected, setSelected] = useState(null); + + const params = useMemo(() => { + const p = new URLSearchParams(); + if (traceId.trim()) p.set("traceId", traceId.trim()); + if (actionFilter.trim()) p.set("action", actionFilter.trim()); + if (level !== "__all__") p.set("level", level); + const since = rangeToSince(range); + if (since) p.set("since", since); + return p; + }, [traceId, actionFilter, level, range]); + + const { data, isLoading, refetch, isFetching } = useQuery({ + queryKey: ["admin", "app-logs", traceId, actionFilter, level, range], + queryFn: async () => { + const res = await fetch(`/api/admin/app-logs?${params.toString()}`); + if (!res.ok) throw new Error("Failed to load app logs"); + return (await res.json()).data as AppLogsResponse; + }, + refetchInterval: 30_000, + }); + + const events = data?.events ?? []; + + return ( +
+
+
+ +
+

+ {t("admin.section.app-logs.title")} +

+

+ {t("admin.section.app-logs.processNote")} +

+
+
+ +
+ +
+ setTraceId(e.target.value)} + aria-label={t("admin.section.app-logs.filterTraceId")} + /> + setActionFilter(e.target.value)} + aria-label={t("admin.section.app-logs.filterAction")} + /> + + +
+ + {isLoading ? ( +
+ +
+ ) : events.length === 0 ? ( + } + title={t("admin.section.app-logs.empty")} + description={t("admin.section.app-logs.emptyDescription")} + /> + ) : ( +
+ + + + + + + + + + + + {events.map((event, i) => ( + setSelected(event)} + > + + + + + + + ))} + +
+ {t("admin.section.app-logs.colLevel")} + + {t("admin.section.app-logs.colTimestamp")} + + {t("admin.section.app-logs.colAction")} + + {t("admin.section.app-logs.colDuration")} + + {t("admin.section.app-logs.colTraceId")} +
{levelIcon(event.level)} + {formatDateTime(event.timestamp)} + + {event.action?.name ?? + (`${event.http?.method ?? ""} ${event.http?.path ?? ""}`.trim() || + event.kind)} + + {event.duration_ms} ms + + {event.trace_id.slice(0, 8)}… +
+
+ + {t("admin.section.app-logs.showing", { + count: events.length, + bufferMax: data?.meta.bufferMax ?? 500, + })} + +
+
+ )} + + !open && setSelected(null)} + > + + + + {selected && levelIcon(selected.level)} + + {selected?.action?.name ?? + selected?.http?.path ?? + t("admin.section.app-logs.eventDetails")} + + + +
+ {selected && ( +
+                {JSON.stringify(selected, null, 2)}
+              
+ )} +
+ + + +
+
+
+ ); +} diff --git a/src/components/insights/__tests__/insight-citation-footnote.test.tsx b/src/components/insights/__tests__/insight-citation-footnote.test.tsx new file mode 100644 index 000000000..b989d8dc2 --- /dev/null +++ b/src/components/insights/__tests__/insight-citation-footnote.test.tsx @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// HealthChart pulls in TanStack Query + recharts. Stub it for the +// same reasons the sibling card test does. +vi.mock("@/components/charts/health-chart", () => ({ + HealthChart: ({ types, title }: { types: string[]; title: string }) => ( +
+ chart:{title} +
+ ), +})); + +vi.mock("@/components/charts/mood-chart", () => ({ + MoodChart: () =>
, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { InsightAdvisorCard } from "../insight-advisor-card"; +import type { InsightResult } from "@/lib/ai/types"; +import { MEDICAL_REFERENCES } from "@/lib/ai/medical-references"; + +const knownRef = MEDICAL_REFERENCES[0]; + +function buildInsight(overrides: Partial = {}): InsightResult { + return { + insightType: "general", + summary: "All good", + classification: "gut", + classificationLabel: "Good", + findings: [ + { + label: "Heart rate stable", + value: "72 bpm", + assessment: "positive", + }, + ], + correlations: [], + primaryRecommendation: "Keep going", + recommendations: [], + dataQuality: { + coverage: "good", + gaps: [], + confidence: "hoch", + }, + disclaimer: "Not a substitute for medical advice.", + ...overrides, + }; +} + +function render(insight: InsightResult, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — recommendation citation footnote (B5a)", () => { + it("renders no footnote for legacy string recommendations", () => { + const html = render( + buildInsight({ + recommendations: ["Continue daily logging."], + }), + ); + expect(html).toContain("Continue daily logging."); + expect(html).not.toContain('data-slot="insight-recommendation-source"'); + expect(html).not.toContain("Source:"); + }); + + it("renders no footnote for structured rec WITHOUT a referenceId", () => { + const html = render( + buildInsight({ + recommendations: [{ text: "Keep an eye on your sleep." }], + }), + ); + expect(html).toContain("Keep an eye on your sleep."); + expect(html).not.toContain('data-slot="insight-recommendation-source"'); + }); + + it("renders the footnote with EN title + URL when referenceId resolves", () => { + const html = render( + buildInsight({ + recommendations: [ + { + text: "Aim for a target below 140/90.", + referenceId: knownRef.id, + }, + ], + }), + "en", + ); + expect(html).toContain('data-slot="insight-recommendation-source"'); + expect(html).toContain(`data-reference-id="${knownRef.id}"`); + expect(html).toContain(knownRef.title); + expect(html).toContain(`${knownRef.publishedYear}`); + expect(html).toContain(knownRef.org); + expect(html).toContain(`href="${knownRef.url}"`); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noreferrer"'); + expect(html).toContain("Source:"); + }); + + it("renders the German title under de locale", () => { + const html = render( + buildInsight({ + recommendations: [ + { + text: "Aim for a target below 140/90.", + referenceId: knownRef.id, + }, + ], + }), + "de", + ); + expect(html).toContain(knownRef.titleDe); + // German "Source" label + expect(html).toContain("Quelle:"); + }); + + it("silently drops the footnote for an unknown referenceId (defence in depth)", () => { + // The schema would normally reject this payload, but if a stale + // payload sneaks through, the UI must not render a broken link. + const html = render( + buildInsight({ + recommendations: [ + { + text: "Aim for something.", + referenceId: "fabricated-2099", + }, + ], + }), + ); + expect(html).toContain("Aim for something."); + expect(html).not.toContain('data-slot="insight-recommendation-source"'); + expect(html).not.toContain("Source:"); + }); + + it("renders multiple recs, each with their own footnote when applicable", () => { + const second = MEDICAL_REFERENCES[1] ?? knownRef; + const html = render( + buildInsight({ + recommendations: [ + { text: "First rec", referenceId: knownRef.id }, + { text: "Second rec", referenceId: second.id }, + "Third plain rec", + ], + }), + ); + expect(html).toContain("First rec"); + expect(html).toContain("Second rec"); + expect(html).toContain("Third plain rec"); + expect(html).toContain(knownRef.url); + if (second !== knownRef) expect(html).toContain(second.url); + // The plain string rec produces no footnote. + const matches = html.match(/data-slot="insight-recommendation-source"/g); + expect(matches?.length ?? 0).toBe(second !== knownRef ? 2 : 2); + }); +}); diff --git a/src/components/insights/insight-advisor-card.tsx b/src/components/insights/insight-advisor-card.tsx index af74dafef..0505244be 100644 --- a/src/components/insights/insight-advisor-card.tsx +++ b/src/components/insights/insight-advisor-card.tsx @@ -1,7 +1,11 @@ "use client"; import { useState } from "react"; -import type { InsightResult, InsightFinding } from "@/lib/ai/types"; +import type { + InsightResult, + InsightFinding, + InsightRecommendation, +} from "@/lib/ai/types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -16,6 +20,7 @@ import { ChevronDown, ChevronUp, Info, + ExternalLink, } from "lucide-react"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { HealthChart } from "@/components/charts/health-chart"; @@ -27,6 +32,8 @@ import { tokenToMetric, type ChartToken, } from "@/lib/insights/chart-tokens"; +import { getMedicalReferenceById } from "@/lib/ai/medical-references"; +import type { Locale } from "@/lib/i18n/config"; // ─── Types ──────────────────────────────────────────────── @@ -263,6 +270,62 @@ function InlineCharts({ tokens }: { tokens: ChartToken[] }) { // ─── Section Separator ──────────────────────────────────── +// ─── Recommendation Citation Footnote ───────────────────── +// +// v1.4.16 phase B5a — render a small "Source: …" footnote under a +// recommendation when the model emitted a `referenceId` pointing into +// the curated medical-reference bundle. The link opens the guideline +// in a new tab; per Marc's research §7 we never open the URL inside +// an in-app webview. +// +// `getMedicalReferenceById()` returns undefined for unknown ids — in +// that case we silently drop the footnote rather than render a broken +// link. The schema check would already have rejected the payload, so +// this is defence in depth. + +function CitationFootnote({ + referenceId, + locale, +}: { + referenceId: string; + locale: Locale; +}) { + const { t } = useTranslations(); + const ref = getMedicalReferenceById(referenceId); + if (!ref) return null; + const label = locale === "de" ? ref.titleDe : ref.title; + return ( + + + {t("insights.recommendation.source")}: + + + {ref.org} {ref.publishedYear} — {label} + + + ); +} + +/** Normalise the legacy string-rec OR new structured-rec into a uniform shape. */ +function normaliseRecommendation(rec: InsightRecommendation): { + text: string; + referenceId?: string; +} { + if (typeof rec === "string") return { text: rec }; + return { text: rec.text, referenceId: rec.referenceId }; +} + +// ─── Section Separator ──────────────────────────────────── + function SectionSeparator({ label }: { label: string }) { return (
@@ -284,7 +347,7 @@ export function InsightAdvisorCard({ regenerating = false, cachedAt, }: InsightAdvisorCardProps) { - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const fmt = useFormatters(); const [dataQualityOpen, setDataQualityOpen] = useState(false); @@ -507,14 +570,25 @@ export function InsightAdvisorCard({
    - {insight.recommendations.map((rec, i) => ( -
  1. - - {i + 1}. - - {rec} -
  2. - ))} + {insight.recommendations.map((rec, i) => { + const { text, referenceId } = normaliseRecommendation(rec); + return ( +
  3. +
    + + {i + 1}. + + {text} +
    + {referenceId && ( + + )} +
  4. + ); + })}
)} diff --git a/src/lib/ai/types.ts b/src/lib/ai/types.ts index f84e81de8..d580dc3be 100644 --- a/src/lib/ai/types.ts +++ b/src/lib/ai/types.ts @@ -21,6 +21,23 @@ export const insightDataQualitySchema = z.object({ confidence: z.enum(["hoch", "mittel", "gering"]), }); +/** + * v1.4.16 — recommendations can be either the legacy plain-string + * shape OR a structured object carrying an optional medical-reference + * citation. The card renders a footnote with a labelled link when + * `referenceId` is set and resolves to a known entry in + * `MEDICAL_REFERENCES`. + */ +export const insightRecommendationSchema = z.union([ + z.string(), + z.object({ + text: z.string(), + referenceId: z.string().optional(), + }), +]); + +export type InsightRecommendation = z.infer; + export const insightResultSchema = z.object({ insightType: z.string().optional(), summary: z.string(), @@ -35,7 +52,7 @@ export const insightResultSchema = z.object({ findings: z.array(insightFindingSchema), correlations: z.array(insightCorrelationSchema), primaryRecommendation: z.string().optional(), - recommendations: z.array(z.string()), + recommendations: z.array(insightRecommendationSchema), dataQuality: insightDataQualitySchema, disclaimer: z.string(), }); diff --git a/src/lib/logging/__tests__/in-memory-buffer.test.ts b/src/lib/logging/__tests__/in-memory-buffer.test.ts new file mode 100644 index 000000000..3a72ebb47 --- /dev/null +++ b/src/lib/logging/__tests__/in-memory-buffer.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + appendLogEvent, + readLogBuffer, + clearLogBuffer, + LOG_BUFFER_MAX, +} from "../in-memory-buffer"; +import type { WideEvent } from "../types"; + +function makeEvent(overrides: Partial = {}): 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, + }; +} + +describe("in-memory log buffer", () => { + beforeEach(() => { + clearLogBuffer(); + }); + + it("returns nothing when empty", () => { + expect(readLogBuffer({})).toEqual([]); + }); + + it("stores appended events and returns them newest-first", () => { + const a = makeEvent({ timestamp: "2026-05-09T20:00:00.000Z" }); + const b = makeEvent({ timestamp: "2026-05-09T20:01:00.000Z" }); + const c = makeEvent({ timestamp: "2026-05-09T20:02:00.000Z" }); + appendLogEvent(a); + appendLogEvent(b); + appendLogEvent(c); + const out = readLogBuffer({}); + expect(out).toHaveLength(3); + // Newest-first + expect(out[0].timestamp).toBe(c.timestamp); + expect(out[1].timestamp).toBe(b.timestamp); + expect(out[2].timestamp).toBe(a.timestamp); + }); + + it("FIFO-evicts when capacity is exceeded (cap = LOG_BUFFER_MAX)", () => { + expect(LOG_BUFFER_MAX).toBe(500); + for (let i = 0; i < 510; i++) { + appendLogEvent(makeEvent({ request_id: `req-${i}` })); + } + const out = readLogBuffer({}); + expect(out).toHaveLength(500); + // oldest 10 events were dropped — req-0..req-9 must NOT be present + const ids = new Set(out.map((e) => e.request_id)); + expect(ids.has("req-0")).toBe(false); + expect(ids.has("req-9")).toBe(false); + expect(ids.has("req-10")).toBe(true); + expect(ids.has("req-509")).toBe(true); + }); + + it("filters by traceId", () => { + appendLogEvent(makeEvent({ trace_id: "alpha" })); + appendLogEvent(makeEvent({ trace_id: "beta" })); + appendLogEvent(makeEvent({ trace_id: "alpha" })); + const out = readLogBuffer({ traceId: "alpha" }); + expect(out).toHaveLength(2); + expect(out.every((e) => e.trace_id === "alpha")).toBe(true); + }); + + it("filters by level", () => { + appendLogEvent(makeEvent({ level: "info" })); + appendLogEvent(makeEvent({ level: "warn" })); + appendLogEvent(makeEvent({ level: "error" })); + expect(readLogBuffer({ level: "warn" })).toHaveLength(1); + expect(readLogBuffer({ level: "error" })).toHaveLength(1); + }); + + it("filters by action substring (case-insensitive)", () => { + appendLogEvent(makeEvent({ action: { name: "measurement.create" } })); + appendLogEvent(makeEvent({ action: { name: "auth.login" } })); + appendLogEvent(makeEvent({ action: { name: "measurement.delete" } })); + const out = readLogBuffer({ action: "measurement" }); + expect(out).toHaveLength(2); + expect(out.every((e) => e.action?.name?.startsWith("measurement"))).toBe( + true, + ); + // Case-insensitive + expect(readLogBuffer({ action: "AUTH" })).toHaveLength(1); + }); + + it("filters by since/until window", () => { + appendLogEvent(makeEvent({ timestamp: "2026-05-09T10:00:00.000Z" })); + appendLogEvent(makeEvent({ timestamp: "2026-05-09T12:00:00.000Z" })); + appendLogEvent(makeEvent({ timestamp: "2026-05-09T14:00:00.000Z" })); + const out = readLogBuffer({ + since: new Date("2026-05-09T11:00:00.000Z"), + until: new Date("2026-05-09T13:00:00.000Z"), + }); + expect(out).toHaveLength(1); + expect(out[0].timestamp).toBe("2026-05-09T12:00:00.000Z"); + }); + + it("respects the limit option", () => { + for (let i = 0; i < 50; i++) { + appendLogEvent(makeEvent({ request_id: `r-${i}` })); + } + expect(readLogBuffer({ limit: 5 })).toHaveLength(5); + }); + + it("clears the buffer", () => { + appendLogEvent(makeEvent()); + appendLogEvent(makeEvent()); + expect(readLogBuffer({})).toHaveLength(2); + clearLogBuffer(); + expect(readLogBuffer({})).toHaveLength(0); + }); +}); diff --git a/src/lib/logging/__tests__/transports-buffer.test.ts b/src/lib/logging/__tests__/transports-buffer.test.ts new file mode 100644 index 000000000..a526dfeb5 --- /dev/null +++ b/src/lib/logging/__tests__/transports-buffer.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; +import { + clearLogBuffer, + readLogBuffer, + getLogBufferSize, +} from "../in-memory-buffer"; +import { emitEvent } from "../transports"; +import { resetLoggingConfig } from "../config"; +import type { WideEvent } from "../types"; + +function makeEvent(overrides: Partial = {}): WideEvent { + return { + timestamp: new Date().toISOString(), + duration_ms: 42, + request_id: "req-buf-test", + trace_id: "trace-buf-test", + level: "info", + kind: "http", + service: "healthlog", + environment: "test", + ...overrides, + }; +} + +describe("emitEvent → in-memory buffer wiring", () => { + let stdoutSpy: ReturnType; + + beforeEach(() => { + clearLogBuffer(); + resetLoggingConfig(); + // Suppress the JSON line so the test runner stays readable. + stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + }); + + it("pushes the emitted event into the in-memory buffer", () => { + expect(getLogBufferSize()).toBe(0); + emitEvent(makeEvent({ trace_id: "trace-A" })); + emitEvent(makeEvent({ trace_id: "trace-B" })); + expect(getLogBufferSize()).toBe(2); + const events = readLogBuffer({}); + // Newest-first: trace-B was pushed second. + expect(events[0].trace_id).toBe("trace-B"); + expect(events[1].trace_id).toBe("trace-A"); + }); + + it("still writes to stdout (transport contract preserved)", () => { + emitEvent(makeEvent()); + expect(stdoutSpy).toHaveBeenCalledTimes(1); + const arg = stdoutSpy.mock.calls[0][0]; + expect(typeof arg).toBe("string"); + expect(arg as string).toContain("trace-buf-test"); + }); +}); diff --git a/src/lib/logging/in-memory-buffer.ts b/src/lib/logging/in-memory-buffer.ts new file mode 100644 index 000000000..74f052aa8 --- /dev/null +++ b/src/lib/logging/in-memory-buffer.ts @@ -0,0 +1,93 @@ +/** + * Per-process in-memory ring buffer for the most recent wide events. + * + * Used by `/admin/app-logs` so admins can drill into structured wide-events + * without standing up a Loki stack just to read the last hour of traffic. + * Events are pushed by `transports.emitEvent()` after they pass the sampler + * gate (so the buffer mirrors what a Loki sink would receive, not the raw + * pre-sampling firehose). + * + * Limitations: + * - Per-process. Under `HEALTHLOG_PROCESS_TYPE=web,worker` split, the + * web buffer doesn't see worker events and vice-versa. The UI surfaces + * this caveat in the section header. + * - 500-entry cap (~500KB max @ ~1KB/event). FIFO eviction on overflow. + * - Volatile — restart drops the buffer. For durable storage configure + * `LOKI_ENDPOINT` and tail Loki instead. + * + * Privacy: events stored raw. The API render path (`/api/admin/app-logs`) + * runs `redactSecrets()` on egress so a stray Bearer token in an error + * message never leaks to the admin UI. + */ +import type { WideEvent, LogLevel } from "./types"; + +export const LOG_BUFFER_MAX = 500; + +// Plain array with FIFO eviction. A linked list would be technically tighter +// but for 500 entries the array shift overhead (~microseconds) is invisible +// next to the rest of the request lifecycle. +let buffer: WideEvent[] = []; + +/** Append an event to the buffer; evict the oldest entry once cap is hit. */ +export function appendLogEvent(event: WideEvent): void { + buffer.push(event); + if (buffer.length > LOG_BUFFER_MAX) { + buffer.shift(); + } +} + +export interface ReadLogBufferOptions { + /** Exact match on `event.trace_id`. */ + traceId?: string; + /** Exact match on `event.level`. */ + level?: LogLevel; + /** Case-insensitive substring match on `event.action.name`. */ + action?: string; + /** Inclusive lower bound on `event.timestamp`. */ + since?: Date; + /** Inclusive upper bound on `event.timestamp`. */ + until?: Date; + /** Cap the number of returned entries. */ + limit?: number; +} + +/** + * Read the buffer with optional filters. Results are returned newest-first + * (descending by insertion order, which matches `event.timestamp` since we + * push at request-finish time). + */ +export function readLogBuffer(opts: ReadLogBufferOptions): WideEvent[] { + const sinceMs = opts.since?.getTime(); + const untilMs = opts.until?.getTime(); + const actionLower = opts.action?.toLowerCase(); + + // Walk back-to-front so the result is newest-first without an extra reverse. + const out: WideEvent[] = []; + for (let i = buffer.length - 1; i >= 0; i--) { + const event = buffer[i]; + if (opts.traceId && event.trace_id !== opts.traceId) continue; + if (opts.level && event.level !== opts.level) continue; + if (actionLower) { + const name = event.action?.name?.toLowerCase() ?? ""; + if (!name.includes(actionLower)) continue; + } + if (sinceMs !== undefined || untilMs !== undefined) { + const ts = Date.parse(event.timestamp); + if (sinceMs !== undefined && ts < sinceMs) continue; + if (untilMs !== undefined && ts > untilMs) continue; + } + out.push(event); + if (opts.limit !== undefined && out.length >= opts.limit) break; + } + return out; +} + +/** Reset the buffer (test-only — not exported from the package index). */ +export function clearLogBuffer(): void { + buffer = []; +} + +/** Current buffer size (test-only). */ +export function getLogBufferSize(): number { + return buffer.length; +}