SRC-08: gespeicherte-suchanfragen

Gespeicherte Suchanfragen: Suchkriterien inklusive aktiver Filter
benannt speichern und live wiederausführen.

- savedsearch/store.go: Postgres-Store, Save (Upsert über tenant_slug/
  user_id/name), List/Get streng auf Mandant+Benutzer beschränkt,
  Delete entfernt genau eine Zeile. Execute führt jede Ausführung LIVE
  gegen search.Client aus, kein eingefrorener Snapshot.
- search/facets.go: kleinste nötige Erweiterung — Client.SearchWithFilters
  (gemeinsame buildFilteredMust-Hilfsfunktion mit Facets extrahiert)
  liefert tatsächlich gefilterte Treffer statt nur Zählungen, sonst gäbe
  es keinen echten Weg, gespeicherte Filter beim Wiederausführen
  anzuwenden.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-08-PRUEFPROTOKOLL.md):
1. TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly: 2
   kombinierte Filter liefern real genau das eine passende Dokument.
2. TestList_UserSeesNoOtherTenantsSavedSearches: Mandant B sieht real
   keine gespeicherten Suchen von Mandant A.
3. TestDelete_RemovesOnlyThatSavedSearch: Löschen entfernt real nur die
   eine gespeicherte Suche, die andere bleibt unverändert.

Kein Umbau: Search/Index/Delete-Verhalten sonst 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 13:31:19 +02:00
co-authored by Claude Sonnet 5
parent 6b5cefc20f
commit ff4d716b94
5 changed files with 513 additions and 7 deletions
+67 -7
View File
@@ -79,12 +79,11 @@ func isFacetField(field string) bool {
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) {
// buildFilteredMust baut die gemeinsame bool.must-Liste für Facets und
// SearchWithFilters: Tenant-Filter zwingend, optionaler Suchtext, dann je
// Filter eine zusätzliche equals-Klausel (UND-Verknüpfung) — einzige
// Stelle, an der Filter-Feldnamen gegen FacetFields geprüft werden.
func buildFilteredMust(tenantSlug, queryText string, filters []FacetFilter) ([]map[string]any, error) {
must := []map[string]any{
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
}
@@ -93,10 +92,71 @@ func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filte
}
for _, f := range filters {
if !isFacetField(f.Field) {
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
return nil, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
}
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
}
return must, nil
}
// SearchWithFilters ist Search, zusätzlich beschränkt auf Dokumente, die
// ALLE angegebenen Filter erfüllen (UND-Verknüpfung, dieselbe Semantik wie
// Facets) — Grundlage für SRC-08s Wiederausführung gespeicherter Suchen
// mit Filtern.
func (c *Client) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) ([]Result, error) {
if len(filters) == 0 {
return c.Search(ctx, tenantSlug, queryText)
}
must, err := buildFilteredMust(tenantSlug, queryText, filters)
if err != nil {
return nil, err
}
payload := map[string]any{
"index": IndexName,
"query": map[string]any{"bool": map[string]any{"must": must}},
"options": map[string]any{
"field_weights": fieldWeights,
},
"limit": searchResultLimit,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("search: gefilterte suchanfrage serialisieren: %w", err)
}
respBody, err := c.doSearchWithSwapRetry(ctx, body)
if err != nil {
return nil, err
}
var parsed searchResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("search: antwort parsen: %w", err)
}
results := make([]Result, 0, len(parsed.Hits.Hits))
for _, hit := range parsed.Hits.Hits {
results = append(results, Result{
MessageID: hit.Source.MessageID,
Subject: hit.Source.Subject,
Score: hit.Score,
SentAtUnixEpoch: hit.Source.SentAtUnixEpoch,
})
}
return results, nil
}
// 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, err := buildFilteredMust(tenantSlug, queryText, filters)
if err != nil {
return FacetResult{}, err
}
aggs := map[string]any{}
for _, field := range FacetFields {