SRC-04: such-oberflaeche-mit-hervorhebung

Such-Oberfläche (Next.js/React/TS) mit Live-Trefferliste und Hervorhebung
der Suchbegriffe im Kontext (Snippet), auf web/shl (SHL-01) aufbauend.

- app/api/search/route.ts: schlanke Backend-for-Frontend-Route gegen
  dieselbe Manticore-Instanz wie mail/internal/search (SRC-01/SRC-03),
  fordert Highlights mit eigenen Markern statt HTML an.
- lib/highlight.ts: zerlegt markierten Snippet-Text in reine
  Textsegmente, kein dangerouslySetInnerHTML — Mailinhalte werden nie
  als HTML interpretiert.
- app/page.tsx: Sucheingabe, Trefferliste mit <mark>-Hervorhebung,
  verständlicher Hinweis bei leerem Ergebnis.
- app/mail/[messageId]/page.tsx: öffnet mit Anker #fundstelle und
  hervorgehobenem Snippet (voller Mail-Inhaltsabruf folgt mit INT-01).
- lib/contrast.ts: reale WCAG-2.1-Kontrastberechnung.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-04-PRUEFPROTOKOLL.md):
1. Manueller Test gegen echten next start + live Manticore auf
   192.168.1.131: Hervorhebung real bestätigt.
2. lib/highlightColors.test.ts: echte WCAG-Berechnung, Hell 14,29:1,
   Dunkel 6,43:1 (>= 4.5:1 AA).
3. Sonderzeichen-Anfrage real gegen laufenden Server: 200 OK, kein
   Absturz; zusätzlich automatisiert gegen Skript-Tags/Unicode.

Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
sysops
2026-08-31 10:26:02 +02:00
co-authored by Claude Sonnet 5
parent 9748307f12
commit d23438d3d0
17 changed files with 695 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
// SRC-04: Backend-for-Frontend-Route für die Such-Oberfläche. Spricht
// direkt mit Manticore (dieselbe Instanz wie mail/internal/search, SRC-01/
// SRC-03) — bewusst KEINE Kopie der vollständigen Go-Suchlogik, sondern nur
// der für die Trefferliste + Snippet-Hervorhebung nötige minimale
// Ausschnitt ("Bereite höchstens die Schnittstelle dafür vor", INT-01
// baut später die vollständige, allgemeine REST-API v1 für Mail-Zugriff).
//
// Statische Feld-/Indexnamen, kein Sprintf/Join-artiger Klauselbau (gleiche
// Konvention wie mail/internal/search/fields.go).
import { NextRequest, NextResponse } from "next/server";
import { HIGHLIGHT_AFTER, HIGHLIGHT_BEFORE } from "../../../lib/highlight";
const INDEX_NAME = "mail_documents";
const FIELD_TENANT_SLUG = "tenant_slug";
function manticoreURL(): string {
const base = process.env.MANTICORE_URL;
if (!base) {
throw new Error("MANTICORE_URL ist nicht gesetzt (Umgebungsvariable erforderlich)");
}
return base.replace(/\/$/, "");
}
interface ManticoreHit {
_score: number;
_source: { message_id: string };
highlight?: { subject?: string[]; body?: string[] };
}
interface ManticoreSearchResponse {
hits?: { hits?: ManticoreHit[] };
error?: string;
}
export async function GET(request: NextRequest) {
const tenantSlug = request.nextUrl.searchParams.get("tenant");
const query = request.nextUrl.searchParams.get("q");
if (!tenantSlug || !query) {
return NextResponse.json({ error: "'tenant' und 'q' sind erforderlich" }, { status: 400 });
}
const manticorePayload = {
index: INDEX_NAME,
query: {
bool: {
must: [{ equals: { [FIELD_TENANT_SLUG]: tenantSlug } }, { query_string: query }],
},
},
highlight: {
fields: { subject: {}, body: {} },
before_match: HIGHLIGHT_BEFORE,
after_match: HIGHLIGHT_AFTER,
},
};
let manticoreResponse: Response;
try {
manticoreResponse = await fetch(`${manticoreURL()}/search`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(manticorePayload),
});
} catch (err) {
return NextResponse.json({ error: `Suche nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const parsed: ManticoreSearchResponse = await manticoreResponse.json().catch(() => ({}));
if (!manticoreResponse.ok || parsed.error) {
return NextResponse.json({ error: parsed.error ?? "Suche fehlgeschlagen" }, { status: 502 });
}
const hits = (parsed.hits?.hits ?? []).map((hit) => ({
messageId: hit._source.message_id,
subjectSnippet: hit.highlight?.subject?.[0] ?? "",
bodySnippet: hit.highlight?.body?.[0] ?? "",
score: hit._score,
}));
return NextResponse.json({ hits });
}
+30
View File
@@ -0,0 +1,30 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Mail-Suche",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
@@ -0,0 +1,54 @@
"use client";
import { useSearchParams } from "next/navigation";
import { splitHighlighted } from "../../../lib/highlight";
import { HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT } from "../../../lib/highlightColors";
// SRC-04 Akzeptanzkriterium 2: Klick auf einen Suchtreffer öffnet die Mail
// direkt an der Fundstelle. Der vollständige Mail-Inhaltsabruf (Betreff/
// Text laden anhand messageId) ist NICHT Bestandteil dieser Kachel — dafür
// gibt es noch keine HTTP-API (folgt mit INT-01). Bis dahin trägt der
// Suchtreffer-Link Betreff-/Text-Snippet als Kontext mit, damit die
// Fundstelle bereits jetzt real anspring- und hervorhebbar ist; die
// vollständige Mail-Ansicht wird von einer späteren Kachel ergänzt.
function Highlighted({ text }: { text: string }) {
return (
<>
{splitHighlighted(text).map((segment, idx) =>
segment.matched ? (
<mark
key={idx}
id={idx === 0 ? "fundstelle" : undefined}
style={{ background: HIGHLIGHT_BG_LIGHT, color: HIGHLIGHT_FG_LIGHT, padding: "0 2px" }}
>
{segment.text}
</mark>
) : (
<span key={idx}>{segment.text}</span>
)
)}
</>
);
}
export default function MailDetailPage({ params }: { params: { messageId: string } }) {
const searchParams = useSearchParams();
const subject = searchParams.get("subject") ?? "";
const snippet = searchParams.get("snippet") ?? "";
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<p>
<a href="/"> Zurück zur Suche</a>
</p>
<h1>
{subject ? <Highlighted text={subject} /> : params.messageId}
</h1>
{snippet && (
<p>
<Highlighted text={snippet} />
</p>
)}
</main>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import SearchPage from "./page";
const ORIGINAL_ENV = process.env;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_TENANT_SLUG: "acme" };
});
afterEach(() => {
process.env = ORIGINAL_ENV;
vi.restoreAllMocks();
});
function mockSearchResponse(hits: unknown[]) {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(new Response(JSON.stringify({ hits }), { status: 200 }))
);
}
// Akzeptanzkriterium 1: Eingabe liefert Trefferliste mit hervorgehobenen
// Suchbegriffen im Snippet.
describe("SearchPage — Trefferliste mit Hervorhebung", () => {
it("rendert Treffer mit <mark> um den hervorgehobenen Suchbegriff", async () => {
mockSearchResponse([
{
messageId: "msg-1",
subjectSnippet: "Quartalsbericht ⦃⦃Umsatz⦄⦄",
bodySnippet: "hoher ⦃⦃Umsatz⦄⦄ im dritten Quartal",
score: 100,
},
]);
render(<SearchPage />);
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Umsatz" } });
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
await waitFor(() => {
const marks = document.querySelectorAll("mark");
expect(marks.length).toBeGreaterThan(0);
});
const marks = Array.from(document.querySelectorAll("mark")).map((m) => m.textContent);
expect(marks).toContain("Umsatz");
});
// Akzeptanzkriterium 3: leere Ergebnisse zeigen verständlichen Hinweis
// statt leerer Fläche.
it("zeigt bei leerem Ergebnis einen verständlichen Hinweis", async () => {
mockSearchResponse([]);
render(<SearchPage />);
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "nichts-vorhanden" } });
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
await waitFor(() => {
expect(screen.getByRole("status")).toHaveTextContent("Keine Treffer");
});
});
// Pflichtprüfung 3: Sonderzeichen in der Suchanfrage bringen die Anzeige
// nicht zum Absturz.
it("wirft bei Sonderzeichen in der Suchanfrage keinen Fehler", async () => {
mockSearchResponse([
{
messageId: "msg-2",
subjectSnippet: `⦃⦃<script>alert(1)</script>⦄⦄ & "Zitat" 日本語`,
bodySnippet: "",
score: 1,
},
]);
render(<SearchPage />);
fireEvent.change(screen.getByLabelText("Suchbegriff"), {
target: { value: '"<script>" OR -Ümläüt 日本語' },
});
expect(() => fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!)).not.toThrow();
await waitFor(() => {
expect(document.querySelectorAll("mark").length).toBeGreaterThan(0);
});
// Kein <script>-Element im DOM entstanden (kein dangerouslySetInnerHTML,
// Manticore-Highlight wird als reiner Text gerendert, nicht als HTML).
expect(document.querySelectorAll("script").length).toBe(0);
});
// Akzeptanzkriterium 2: Klick auf Treffer öffnet die zugehörige Mail
// direkt an der Fundstelle — hier geprüft über den erzeugten Link, da
// die vollständige Mail-Ansicht (Inhaltsabruf per API) INT-01 vorbehalten
// ist.
it("verlinkt jeden Treffer auf die Mail-Detailseite mit Fundstellen-Anker", async () => {
mockSearchResponse([
{ messageId: "msg-3", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 },
]);
render(<SearchPage />);
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
await waitFor(() => {
const link = screen.getByRole("link");
expect(link.getAttribute("href")).toMatch(/^\/mail\/msg-3\?.*#fundstelle$/);
});
});
});
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useState, type FormEvent } from "react";
import Link from "next/link";
import { TextField } from "@nexarch/shl";
import { search, ApiError, type SearchHit } from "../lib/api";
import { splitHighlighted } from "../lib/highlight";
import { HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT } from "../lib/highlightColors";
function tenantSlug(): string {
return process.env.NEXT_PUBLIC_MAIL_TENANT_SLUG ?? "";
}
function Snippet({ text }: { text: string }) {
if (!text) {
return null;
}
return (
<span>
{splitHighlighted(text).map((segment, idx) =>
segment.matched ? (
<mark
key={idx}
style={{ background: HIGHLIGHT_BG_LIGHT, color: HIGHLIGHT_FG_LIGHT, padding: "0 2px" }}
>
{segment.text}
</mark>
) : (
<span key={idx}>{segment.text}</span>
)
)}
</span>
);
}
function resultHref(hit: SearchHit): string {
const params = new URLSearchParams({
subject: hit.subjectSnippet,
snippet: hit.bodySnippet,
});
return `/mail/${encodeURIComponent(hit.messageId)}?${params.toString()}#fundstelle`;
}
export default function SearchPage() {
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function runSearch(e?: FormEvent) {
e?.preventDefault();
if (!query.trim()) {
setHits(null);
setError(null);
return;
}
setLoading(true);
setError(null);
try {
const result = await search(tenantSlug(), query);
setHits(result.hits);
} catch (err) {
// Akzeptanzkriterium 3 (sinngemäß auf Fehlerfall übertragen): auch
// ein Suchfehler zeigt einen verständlichen Hinweis statt einer
// leeren Fläche.
setHits([]);
setError(err instanceof ApiError ? err.message : "Suche konnte nicht ausgeführt werden.");
} finally {
setLoading(false);
}
}
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<h1>Mail-Suche</h1>
<form onSubmit={runSearch}>
<TextField
label="Suchbegriff"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='z. B. "Quartalsbericht" oder Umsatz -Verlust'
/>
<button type="submit" disabled={loading} style={{ marginTop: 8 }}>
Suchen
</button>
</form>
{error && (
<p role="alert" style={{ marginTop: 16 }}>
{error}
</p>
)}
{hits !== null && hits.length === 0 && !error && (
<p role="status" style={{ marginTop: 16 }}>
Keine Treffer für diese Suchanfrage.
</p>
)}
{hits !== null && hits.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, marginTop: 16 }}>
{hits.map((hit) => (
<li key={hit.messageId} style={{ padding: "8px 0", borderBottom: "1px solid var(--shl-color-border, #d7dbe0)" }}>
<Link href={resultHref(hit)}>
<Snippet text={hit.subjectSnippet} />
<div>
<Snippet text={hit.bodySnippet} />
</div>
</Link>
</li>
))}
</ul>
)}
</main>
);
}