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,69 @@
|
|||||||
|
# SRC-04 – Prüfprotokoll: Such-Oberfläche mit Hervorhebung
|
||||||
|
|
||||||
|
Voraussetzung SRC-03 (Fertig), SHL-01 (Core-Board, Fertig).
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `web/mail-search`: eigenständige Next.js/React/TypeScript-App (kein
|
||||||
|
Backend-Annex), auf `web/shl` (SHL-01) aufbauend — gleiche Konvention
|
||||||
|
wie `web/retention-admin` (RET-06).
|
||||||
|
- `app/api/search/route.ts`: Backend-for-Frontend-Route, spricht direkt
|
||||||
|
mit derselben Manticore-Instanz wie `mail/internal/search` (SRC-01/
|
||||||
|
SRC-03). Bewusst KEINE Kopie der vollständigen Go-Suchlogik — nur der
|
||||||
|
für Trefferliste + Snippet-Hervorhebung nötige minimale Ausschnitt
|
||||||
|
("Bereite höchstens die Schnittstelle dafür vor"; die allgemeine
|
||||||
|
REST-API v1 für Mail-Zugriff ist INT-01, nicht Bestandteil dieser
|
||||||
|
Kachel). Statische Feld-/Indexnamen, kein Sprintf/Join-Klauselbau
|
||||||
|
(gleiche Konvention wie `fields.go`). Fordert Manticore-Highlights mit
|
||||||
|
eigenen Markern (`⦃⦃`/`⦄⦄`) statt HTML an.
|
||||||
|
- `lib/highlight.ts`: `splitHighlighted` zerlegt den markierten Snippet-
|
||||||
|
Text in reine Textsegmente — die Komponente rendert sie als Textknoten,
|
||||||
|
**kein** `dangerouslySetInnerHTML`, damit Mailinhalte (nicht
|
||||||
|
vertrauenswürdig) niemals als HTML interpretiert werden können.
|
||||||
|
- `app/page.tsx`: Sucheingabe (`@nexarch/shl` `TextField`), Live-
|
||||||
|
Trefferliste mit `<mark>`-Hervorhebung, verständlicher Hinweis bei
|
||||||
|
leerem Ergebnis, Link je Treffer zur Mail-Detailseite.
|
||||||
|
- `app/mail/[messageId]/page.tsx`: öffnet mit Anker `#fundstelle` und
|
||||||
|
hervorgehobenem Snippet aus den Suchtreffer-Daten. Vollständiger
|
||||||
|
Mail-Inhaltsabruf per messageId existiert noch nicht (keine HTTP-API
|
||||||
|
dafür, folgt mit INT-01) — bis dahin trägt der Link Betreff-/Text-
|
||||||
|
Snippet als Kontext mit, damit die Fundstelle bereits jetzt real
|
||||||
|
anspring- und hervorhebbar ist.
|
||||||
|
- `lib/contrast.ts`/`lib/highlightColors.ts`: reale WCAG-2.1-
|
||||||
|
Kontrastberechnung statt behaupteter Werte.
|
||||||
|
- Kein Umbau: `mail/internal/*`, `web/shl`, `web/retention-admin`
|
||||||
|
unverändert.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Manueller Test mit typischen Suchanfragen bestätigt korrekte Hervorhebung | **bestanden** – echter `next build` + `next start` auf 192.168.1.131 gegen die live laufende Manticore-Instanz: `GET /api/search?tenant=src04-manual&q=Umsatz` liefert real `"subjectSnippet":"Quartalsbericht ⦃⦃Umsatz⦄⦄"` — Marker um exakt den Suchbegriff. Zusätzlich automatisiert in `app/page.test.tsx` (Marker im DOM nach Suche) |
|
||||||
|
| 2 | Barrierefreiheits-Kontrastprüfung der Hervorhebung | **bestanden** – `lib/highlightColors.test.ts`: echte WCAG-2.1-Berechnung, Hell-Modus 14,29:1, Dunkel-Modus 6,43:1 (beide ≥ 4.5:1 AA-Grenzwert für Fließtext) |
|
||||||
|
| 3 | Test mit Sonderzeichen in der Suchanfrage bricht die Anzeige nicht | **bestanden** – real gegen den laufenden Server getestet: Anfrage mit `"dritten Quartal" -Verlust <script>` liefert `200 OK` mit `{"hits":[]}`, kein Absturz. Zusätzlich automatisiert `lib/highlight.test.ts` (Skript-Tags/Unicode/unvollständige Marker als reiner Text) und `app/page.test.tsx` (kein `<script>`-Element im DOM, da kein `dangerouslySetInnerHTML`) |
|
||||||
|
|
||||||
|
Zusätzlich (Akzeptanzkriterium 2/3, real geprüft): `GET /mail/m-manual-1?subject=...`
|
||||||
|
liefert `200 OK`; automatisiert `app/page.test.tsx` bestätigt Link-Struktur
|
||||||
|
(`/mail/<id>?...#fundstelle`) und den "Keine Treffer"-Hinweis bei leerem
|
||||||
|
Ergebnis.
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
npx tsc --noEmit -> clean
|
||||||
|
npx next build -> Compiled successfully (4 Routen)
|
||||||
|
npx vitest run -> 3 Testdateien, 12/12 bestanden
|
||||||
|
next start (real) + curl gegen Manticore live -> Hervorhebung, leeres Ergebnis,
|
||||||
|
Sonderzeichen alle real bestätigt
|
||||||
|
```
|
||||||
|
|
||||||
|
Testprozess (`next start -p 4711`) und Testdokument (`mail_documents`-ID
|
||||||
|
992001) nach Prüfung entfernt.
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt. SRC-04 ist der nächste Schritt in der Suche-Foundation-Kette
|
||||||
|
(Index → Befüllung → Such-API → nutzbare Oberfläche), nicht nur eine nette
|
||||||
|
Ergänzung — ohne ihn bliebe die Such-API ohne für Anwenderinnen und
|
||||||
|
Anwender erreichbaren Zugang. Entsperrt QA-03 (gemeinsam mit SRC-02).
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// SRC-04: dünner Client für die eigene Backend-for-Frontend-Route
|
||||||
|
// app/api/search/route.ts. Kein direkter Zugriff auf Manticore oder eine
|
||||||
|
// externe Mail-API vom Client aus (Rolle: "Datenzugriff ausschließlich
|
||||||
|
// über die bereitgestellte API").
|
||||||
|
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
messageId: string;
|
||||||
|
subjectSnippet: string;
|
||||||
|
bodySnippet: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
hits: SearchHit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantSlug: bis zu einer zentralen Session-/IAM-Anbindung (Core-Board-
|
||||||
|
// Scope, nicht Bestandteil dieser Kachel) wird der Mandant vom Aufrufer
|
||||||
|
// mitgegeben. Die eigentliche Mandantentrennung passiert serverseitig in
|
||||||
|
// mail/internal/search (SRC-01/SRC-03), nicht im Frontend.
|
||||||
|
export async function search(tenantSlug: string, query: string): Promise<SearchResponse> {
|
||||||
|
const params = new URLSearchParams({ tenant: tenantSlug, q: query });
|
||||||
|
const res = await fetch(`/api/search?${params.toString()}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new ApiError(body.error ?? `Suche fehlgeschlagen (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// SRC-04 Prüfung 2 (Barrierefreiheits-Kontrastprüfung der Hervorhebung):
|
||||||
|
// echte WCAG-2.1-Kontrastberechnung statt einer rein manuellen Behauptung.
|
||||||
|
|
||||||
|
function srgbToLinear(channel: number): number {
|
||||||
|
const c = channel / 255;
|
||||||
|
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeLuminance(hex: string): number {
|
||||||
|
const clean = hex.replace("#", "");
|
||||||
|
const r = parseInt(clean.slice(0, 2), 16);
|
||||||
|
const g = parseInt(clean.slice(2, 4), 16);
|
||||||
|
const b = parseInt(clean.slice(4, 6), 16);
|
||||||
|
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// contrastRatio berechnet das WCAG-Kontrastverhältnis zwischen zwei
|
||||||
|
// Hex-Farben (>= 4.5:1 gilt als AA-konform für Fließtext).
|
||||||
|
export function contrastRatio(colorA: string, colorB: string): number {
|
||||||
|
const lumA = relativeLuminance(colorA);
|
||||||
|
const lumB = relativeLuminance(colorB);
|
||||||
|
const lighter = Math.max(lumA, lumB);
|
||||||
|
const darker = Math.min(lumA, lumB);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { HIGHLIGHT_AFTER, HIGHLIGHT_BEFORE, splitHighlighted } from "./highlight";
|
||||||
|
|
||||||
|
describe("splitHighlighted", () => {
|
||||||
|
it("markiert einen einzelnen Treffer korrekt", () => {
|
||||||
|
const snippet = `hoher ${HIGHLIGHT_BEFORE}Umsatz${HIGHLIGHT_AFTER} im dritten Quartal`;
|
||||||
|
expect(splitHighlighted(snippet)).toEqual([
|
||||||
|
{ text: "hoher ", matched: false },
|
||||||
|
{ text: "Umsatz", matched: true },
|
||||||
|
{ text: " im dritten Quartal", matched: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("markiert mehrere Treffer im selben Snippet", () => {
|
||||||
|
const snippet = `${HIGHLIGHT_BEFORE}Umsatz${HIGHLIGHT_AFTER} und ${HIGHLIGHT_BEFORE}Umsatzwachstum${HIGHLIGHT_AFTER}`;
|
||||||
|
const segments = splitHighlighted(snippet);
|
||||||
|
expect(segments.filter((s) => s.matched)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert unveränderten Text ohne Marker als unmarkiertes Segment", () => {
|
||||||
|
expect(splitHighlighted("kein treffer hier")).toEqual([{ text: "kein treffer hier", matched: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bricht bei leerem Snippet nicht ab", () => {
|
||||||
|
expect(splitHighlighted("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pflichtprüfung 3: Sonderzeichen in der Suchanfrage/im indexierten Text
|
||||||
|
// dürfen die Anzeige nicht zum Absturz bringen.
|
||||||
|
it("behandelt Sonderzeichen und potenziell gefährliches Markup als reinen Text", () => {
|
||||||
|
const snippet = `${HIGHLIGHT_BEFORE}<script>alert(1)</script>${HIGHLIGHT_AFTER} & "Zitat" 'Anführung' Ümläüte 日本語`;
|
||||||
|
const segments = splitHighlighted(snippet);
|
||||||
|
expect(segments[0]).toEqual({ text: "<script>alert(1)</script>", matched: true });
|
||||||
|
expect(segments.map((s) => s.text).join("")).toContain("Ümläüte 日本語");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behandelt ein unvollständiges Markerpaar ohne Absturz", () => {
|
||||||
|
const snippet = `abc ${HIGHLIGHT_BEFORE}unvollständig`;
|
||||||
|
expect(() => splitHighlighted(snippet)).not.toThrow();
|
||||||
|
const segments = splitHighlighted(snippet);
|
||||||
|
expect(segments.map((s) => s.text).join("")).toBe("abc unvollständig");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// SRC-04: zerlegt einen von der Mail-Suche gelieferten Snippet-Text in
|
||||||
|
// Textsegmente. Manticore markiert Treffer serverseitig mit den Markern
|
||||||
|
// HIGHLIGHT_BEFORE/HIGHLIGHT_AFTER (siehe app/api/search/route.ts) — bewusst
|
||||||
|
// KEIN HTML von Manticore übernehmen (Mailinhalte sind nicht vertrauenswürdig,
|
||||||
|
// könnten selbst Markup enthalten). splitHighlighted liefert reine
|
||||||
|
// Textsegmente, die die aufrufende Komponente als Textknoten rendert
|
||||||
|
// (kein dangerouslySetInnerHTML nötig, damit keine XSS-Lücke möglich).
|
||||||
|
|
||||||
|
export const HIGHLIGHT_BEFORE = "⦃⦃";
|
||||||
|
export const HIGHLIGHT_AFTER = "⦄⦄";
|
||||||
|
|
||||||
|
export interface HighlightSegment {
|
||||||
|
text: string;
|
||||||
|
matched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitHighlighted(snippet: string): HighlightSegment[] {
|
||||||
|
const segments: HighlightSegment[] = [];
|
||||||
|
let rest = snippet;
|
||||||
|
|
||||||
|
while (rest.length > 0) {
|
||||||
|
const startIdx = rest.indexOf(HIGHLIGHT_BEFORE);
|
||||||
|
if (startIdx === -1) {
|
||||||
|
segments.push({ text: rest, matched: false });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (startIdx > 0) {
|
||||||
|
segments.push({ text: rest.slice(0, startIdx), matched: false });
|
||||||
|
}
|
||||||
|
const afterStart = rest.slice(startIdx + HIGHLIGHT_BEFORE.length);
|
||||||
|
const endIdx = afterStart.indexOf(HIGHLIGHT_AFTER);
|
||||||
|
if (endIdx === -1) {
|
||||||
|
// Unvollständiges Markerpaar (sollte bei korrekter Manticore-Antwort
|
||||||
|
// nicht vorkommen) — Rest als unmarkierten Text behandeln, statt die
|
||||||
|
// Anzeige abstürzen zu lassen (Pflichtprüfung 3).
|
||||||
|
segments.push({ text: afterStart, matched: false });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
segments.push({ text: afterStart.slice(0, endIdx), matched: true });
|
||||||
|
rest = afterStart.slice(endIdx + HIGHLIGHT_AFTER.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { contrastRatio } from "./contrast";
|
||||||
|
import { HIGHLIGHT_BG_DARK, HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_DARK, HIGHLIGHT_FG_LIGHT } from "./highlightColors";
|
||||||
|
|
||||||
|
// Pflichtprüfung 2 (Barrierefreiheits-Kontrastprüfung der Hervorhebung):
|
||||||
|
// echte WCAG-2.1-AA-Berechnung (>= 4.5:1 für Fließtext), nicht nur eine
|
||||||
|
// manuelle Sichtprüfung.
|
||||||
|
describe("Hervorhebungs-Kontrast (WCAG 2.1 AA)", () => {
|
||||||
|
it("Hell-Modus erreicht mindestens 4.5:1", () => {
|
||||||
|
const ratio = contrastRatio(HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT);
|
||||||
|
expect(ratio).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Dunkel-Modus erreicht mindestens 4.5:1", () => {
|
||||||
|
const ratio = contrastRatio(HIGHLIGHT_BG_DARK, HIGHLIGHT_FG_DARK);
|
||||||
|
expect(ratio).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// SRC-04 Prüfung 2: Kontrastwerte real berechnet in
|
||||||
|
// lib/highlightColors.test.ts (contrastRatio aus lib/contrast.ts),
|
||||||
|
// nicht nur behauptet.
|
||||||
|
export const HIGHLIGHT_BG_LIGHT = "#FDE68A";
|
||||||
|
export const HIGHLIGHT_FG_LIGHT = "#14181F";
|
||||||
|
|
||||||
|
export const HIGHLIGHT_BG_DARK = "#92400E";
|
||||||
|
export const HIGHLIGHT_FG_DARK = "#F2F4F7";
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
transpilePackages: ["@nexarch/shl"],
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "nexarch-mail-search",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nexarch/shl": "file:../shl",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "6.4.8",
|
||||||
|
"@testing-library/react": "16.0.0",
|
||||||
|
"@types/node": "20.14.9",
|
||||||
|
"@types/react": "18.3.3",
|
||||||
|
"@types/react-dom": "18.3.0",
|
||||||
|
"jsdom": "24.1.0",
|
||||||
|
"typescript": "5.5.3",
|
||||||
|
"vitest": "2.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
esbuild: {
|
||||||
|
jsx: "automatic",
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
// SHL-01-Erfahrung (bfa5c61): ohne afterEach(cleanup) stapeln sich
|
||||||
|
// gerenderte DOM-Bäume zwischen Tests.
|
||||||
|
afterEach(cleanup);
|
||||||
Reference in New Issue
Block a user