SRC-05: facetten-filter-api

Facetten- und Filter-API für Suche (Absender, Zeitraum, Postfach,
Anhangstyp, Tag), mandantengetrennt, mit UND-Verknüpfung mehrerer Filter.

- migrations/0002..0005: vier nummerierte ALTER-Migrationen für die neuen
  Facettenfelder (Manticore erlaubt nur eine Spalte je ALTER-Anweisung),
  EnsureSchema wendet sie idempotent nach.
- fields.go: FacetFields-Whitelist, einzige zulässige Facettendimensionen.
- facets.go: Client.Facets nutzt Manticores strukturierte aggs.terms/
  aggs.range-API, kein dynamischer SQL-Klauselbau. Filter kombinieren als
  zusätzliche equals-Klauseln in derselben bool.must-Liste wie der
  Tenant-Filter. Zeitraum-Facette über feste Buckets via aggs.range.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-05-PRUEFPROTOKOLL.md):
1. TestFacets_CountsMatchActualHits: Facettenzahlen stimmen real mit der
   tatsächlichen Treffermenge überein.
2. TestFacets_ThreeFiltersCombineWithAND: 3 kombinierte Filter schränken
   4 Dokumente real auf genau 1 verbleibenden Treffer ein.
3. TestFacets_TenantSeparation: Facetten eines Mandanten enthalten real
   keine Werte eines anderen.

Kein Umbau: Search/Delete/Index-Verhalten aus SRC-01/SRC-03 unverändert,
dedup/indexworker/storage/crypto/encstorage 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:49:52 +02:00
co-authored by Claude Sonnet 5
parent d23438d3d0
commit db73aab0de
9 changed files with 413 additions and 6 deletions
+180
View File
@@ -0,0 +1,180 @@
// SRC-05: Facetten- & Filter-API. Nutzt Manticores strukturierte
// Aggregations-API (aggs.terms/aggs.range) — keine dynamische
// SQL-Klauselbildung, dieselbe Konvention wie Search/Delete (fields.go).
package search
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// FacetFilter schränkt Suche/Facettenberechnung auf einen bereits
// gewählten Facettenwert ein. Field MUSS aus FacetFields stammen —
// Facets liefert einen Fehler bei jedem anderen Wert (verhindert einen
// beliebigen, vom Aufrufer bestimmten Feldnamen in der Anfrage).
type FacetFilter struct {
Field string
Value string
}
// FacetValue ist ein einzelner Facettenwert mit Trefferzahl
// (Akzeptanzkriterium 1).
type FacetValue struct {
Value string
Count int64
}
// DateRangeFacet ist ein Zeitraum-Bucket mit Trefferzahl.
type DateRangeFacet struct {
Label string
Count int64
}
// FacetResult fasst alle Facettendimensionen einer Anfrage zusammen.
type FacetResult struct {
// Values ist je FacetFields-Eintrag (sender/mailbox/attachment_type/tag)
// befüllt.
Values map[string][]FacetValue
// DateRanges sind feste Zeitraum-Buckets über FieldSentAt.
DateRanges []DateRangeFacet
}
// farFuture ist die obere Grenze des jüngsten Zeitraum-Buckets. Manticores
// range-Aggregation verlangt für jeden Bucket ein explizites "to" — ein
// hinreichend großer fester Wert (Jahr 2100) übernimmt die Rolle von
// "unbegrenzt in die Zukunft", ohne den Feldtyp zu wechseln.
const farFuture int64 = 4102444800
type dateRangeBoundary struct {
label string
from int64 // 0 = ab Epoch (unbegrenzt in die Vergangenheit)
to int64
}
// dateRangeBoundaries berechnet die festen Zeitraum-Buckets relativ zu now
// (Parameter statt time.Now() direkt, damit Facets testbar bleibt).
func dateRangeBoundaries(now time.Time) []dateRangeBoundary {
sevenDaysAgo := now.AddDate(0, 0, -7).Unix()
thirtyDaysAgo := now.AddDate(0, 0, -30).Unix()
oneYearAgo := now.AddDate(-1, 0, 0).Unix()
return []dateRangeBoundary{
{label: "letzte_7_tage", from: sevenDaysAgo, to: farFuture},
{label: "letzte_30_tage", from: thirtyDaysAgo, to: sevenDaysAgo},
{label: "letztes_jahr", from: oneYearAgo, to: thirtyDaysAgo},
{label: "aelter", from: 0, to: oneYearAgo},
}
}
func isFacetField(field string) bool {
for _, f := range FacetFields {
if f == field {
return true
}
}
return false
}
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
// wie der Tenant-Filter).
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
must := []map[string]any{
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
}
if queryText != "" {
must = append(must, map[string]any{"query_string": queryText})
}
for _, f := range filters {
if !isFacetField(f.Field) {
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
}
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
}
aggs := map[string]any{}
for _, field := range FacetFields {
aggs[field] = map[string]any{"terms": map[string]any{"field": field, "size": 100}}
}
boundaries := dateRangeBoundaries(time.Now())
ranges := make([]map[string]any, 0, len(boundaries))
for _, b := range boundaries {
ranges = append(ranges, map[string]any{"from": b.from, "to": b.to})
}
aggs["sent_at"] = map[string]any{"range": map[string]any{"field": FieldSentAt, "ranges": ranges}}
payload := map[string]any{
"index": IndexName,
"query": map[string]any{"bool": map[string]any{"must": must}},
"aggs": aggs,
"limit": 0,
}
body, err := json.Marshal(payload)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facettenanfrage serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
if err != nil {
return FacetResult{}, fmt.Errorf("search: facettenanfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facetten abrufen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return FacetResult{}, fmt.Errorf("search: facetten-antwort lesen: %w", err)
}
if resp.StatusCode != http.StatusOK {
return FacetResult{}, fmt.Errorf("search: facetten, status %d: %s", resp.StatusCode, string(respBody))
}
var parsed facetResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return FacetResult{}, fmt.Errorf("search: facetten-antwort parsen: %w", err)
}
result := FacetResult{Values: make(map[string][]FacetValue, len(FacetFields))}
for _, field := range FacetFields {
bucket := parsed.Aggregations[field]
values := make([]FacetValue, 0, len(bucket.Buckets))
for _, b := range bucket.Buckets {
if b.Key == "" {
continue
}
values = append(values, FacetValue{Value: b.Key, Count: b.DocCount})
}
result.Values[field] = values
}
sentAtBucket := parsed.Aggregations["sent_at"]
result.DateRanges = make([]DateRangeFacet, 0, len(boundaries))
for i, b := range boundaries {
count := int64(0)
if i < len(sentAtBucket.Buckets) {
count = sentAtBucket.Buckets[i].DocCount
}
result.DateRanges = append(result.DateRanges, DateRangeFacet{Label: b.label, Count: count})
}
return result, nil
}
type facetResponse struct {
Aggregations map[string]struct {
Buckets []struct {
Key string `json:"key"`
DocCount int64 `json:"doc_count"`
} `json:"buckets"`
} `json:"aggregations"`
}