SRC-06: facetten-ui-filter-chips

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
This commit is contained in:
sysops
2026-08-31 23:18:08 +02:00
co-authored by Claude Sonnet 5
parent 0065b959b3
commit acc2b0c5dd
12 changed files with 563 additions and 30 deletions
+68
View File
@@ -0,0 +1,68 @@
// SRC-06: Backend-for-Frontend-Route für Facettenzählungen. Spiegelt
// mail/internal/search/facets.go (Client.Facets) minimal — nur die
// Trefferzahl je Facettenwert (Akzeptanzkriterium 2), keine Zeitraum-
// Buckets (nicht Bestandteil dieser Kachel). Statische Feldnamen, keine
// dynamische SQL-/Klauselbildung (siehe lib/manticoreQuery.ts).
import { NextRequest, NextResponse } from "next/server";
import { FACET_FIELDS } from "../../../lib/facetFields";
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 ManticoreFacetResponse {
aggregations?: Record<string, { buckets?: { key: string; doc_count: number }[] }>;
error?: string;
}
export async function GET(request: NextRequest) {
const tenantSlug = request.nextUrl.searchParams.get("tenant");
const query = request.nextUrl.searchParams.get("q") ?? "";
if (!tenantSlug) {
return NextResponse.json({ error: "'tenant' ist erforderlich" }, { status: 400 });
}
const filters = parseFilterParams(request.nextUrl.searchParams.getAll("filter"));
const aggs: Record<string, unknown> = {};
for (const field of FACET_FIELDS) {
aggs[field] = { terms: { field, size: 100 } };
}
const manticorePayload = {
index: INDEX_NAME,
query: { bool: { must: buildMust(tenantSlug, query, filters) } },
aggs,
limit: 0,
};
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: `Facetten nicht erreichbar: ${(err as Error).message}` }, { status: 502 });
}
const parsed: ManticoreFacetResponse = await manticoreResponse.json().catch(() => ({}));
if (!manticoreResponse.ok || parsed.error) {
return NextResponse.json({ error: parsed.error ?? "Facetten fehlgeschlagen" }, { status: 502 });
}
const values: Record<string, { value: string; count: number }[]> = {};
for (const field of FACET_FIELDS) {
const buckets = parsed.aggregations?.[field]?.buckets ?? [];
values[field] = buckets.filter((b) => b.key !== "").map((b) => ({ value: b.key, count: b.doc_count }));
}
return NextResponse.json({ values });
}
+4 -2
View File
@@ -9,9 +9,9 @@
// 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";
const FIELD_TENANT_SLUG = "tenant_slug";
function manticoreURL(): string {
const base = process.env.MANTICORE_URL;
@@ -39,11 +39,13 @@ export async function GET(request: NextRequest) {
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: [{ equals: { [FIELD_TENANT_SLUG]: tenantSlug } }, { query_string: query }],
must: buildMust(tenantSlug, query, filters),
},
},
highlight: {