Facetten-Oberfläche mit Filter-Chips: aktive Filter sichtbar, einzeln entfernbar, Trefferzahl je Facettenwert live angezeigt. - app/api/facets/route.ts: neue Backend-for-Frontend-Route, spiegelt mail/internal/search/facets.go minimal (nur Trefferzahl je Facettenwert). - lib/manticoreQuery.ts: gemeinsamer statischer bool.must-Aufbau für Such- und Facetten-Route, kein Sprintf/Join-artiger Klauselbau. - app/api/search/route.ts (SRC-04): akzeptiert jetzt wiederholbare ?filter=feld:wert-Parameter. - app/FacetPanel.tsx: ActiveFilterChips (echte <button>-Elemente, nativ tastaturbedienbar) + FacetPanel (Klick fügt Filter hinzu) + "Alle Filter zurücksetzen". - lib/filterState.ts: reine Filterzustandsfunktionen, ohne React. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-06-PRUEFPROTOKOLL.md): 1. Manueller Test gegen echten next start + live Manticore auf 192.168.1.131: Filterkombination reduziert Treffer UND Facettenzählungen real konsistent von 2 auf 1. 2. @testing-library/user-event: echte Tastatursimulation (Enter) löst Chip-Entfernung real aus. 3. 20 gleichzeitig aktive Filter erzeugen real 20 einzelne, nicht zusammengefasste Chips, kein Absturz. Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert, bestehendes SRC-04-Verhalten unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
// 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";
|
|
import { buildMust, parseFilterParams } from "../../../lib/manticoreQuery";
|
|
|
|
const INDEX_NAME = "mail_documents";
|
|
|
|
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 filters = parseFilterParams(request.nextUrl.searchParams.getAll("filter"));
|
|
|
|
const manticorePayload = {
|
|
index: INDEX_NAME,
|
|
query: {
|
|
bool: {
|
|
must: buildMust(tenantSlug, query, filters),
|
|
},
|
|
},
|
|
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 });
|
|
}
|