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:
co-authored by
Claude Sonnet 5
parent
9748307f12
commit
d23438d3d0
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user