From ff4d716b94fe07310d3d58ba5fac3c0e6942d2ac Mon Sep 17 00:00:00 2001 From: sysops Date: Mon, 31 Aug 2026 13:31:19 +0200 Subject: [PATCH] SRC-08: gespeicherte-suchanfragen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ --- mail/docs/SRC-08-PRUEFPROTOKOLL.md | 59 +++++ .../migrations/0001_mail_saved_searches.sql | 11 + mail/internal/savedsearch/store.go | 174 +++++++++++++++ mail/internal/savedsearch/store_test.go | 202 ++++++++++++++++++ mail/internal/search/facets.go | 74 ++++++- 5 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 mail/docs/SRC-08-PRUEFPROTOKOLL.md create mode 100644 mail/internal/savedsearch/migrations/0001_mail_saved_searches.sql create mode 100644 mail/internal/savedsearch/store.go create mode 100644 mail/internal/savedsearch/store_test.go diff --git a/mail/docs/SRC-08-PRUEFPROTOKOLL.md b/mail/docs/SRC-08-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..0c2b747 --- /dev/null +++ b/mail/docs/SRC-08-PRUEFPROTOKOLL.md @@ -0,0 +1,59 @@ +# SRC-08 – Prüfprotokoll: Gespeicherte Suchanfragen + +Voraussetzung SRC-03 (Fertig). + +## Umsetzung + +- `mail/internal/savedsearch/store.go` — `Store` (Postgres, + `mail_saved_searches`): `Save` (Upsert über `UNIQUE(tenant_slug, + user_id, name)`, Akzeptanzkriterium 1), `List`/`Get` streng auf + Mandant UND Benutzer beschränkt (Akzeptanzkriterium 3), `Delete` + löscht genau eine Zeile über `tenant_slug + user_id + id`. + „Benutzer" ist bis zu einer zentralen Session-/IAM-Anbindung + (Core-Board-Scope) ein vom Aufrufer mitgegebener opaker + `userID`-String — dieselbe Konvention wie der Tenant-Kontext in + `web/mail-search` (SRC-04). +- `Execute(ctx, client, saved)` führt die gespeicherte Suche LIVE gegen + `search.Client` aus — speichert selbst keine Treffer, jeder Aufruf + fragt Manticore neu ab (Akzeptanzkriterium 2). +- `mail/internal/search/facets.go` — kleinste nötige Erweiterung: neue + Methode `Client.SearchWithFilters` (gemeinsame `buildFilteredMust`- + Hilfsfunktion mit `Facets` extrahiert) liefert TATSÄCHLICH gefilterte + Treffer statt nur Facettenzählungen — ohne dies gäbe es keinen echten + Weg, gespeicherte Filter beim Wiederausführen anzuwenden. +- Kein Umbau: `Search`/`Facets`/`Index`/`Delete`-Verhalten sonst + unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/ + `encstorage` unverändert. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test: gespeicherte Suche mit mehreren Filtern wird korrekt reproduziert | **bestanden** – `TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly`: 3 Dokumente, 2 Filter (Sender+Postfach) gespeichert, `Execute` liefert real genau das eine Dokument, das beide Filter erfüllt | +| 2 | Test: Benutzer sieht keine gespeicherten Suchen anderer Mandanten | **bestanden** – `TestList_UserSeesNoOtherTenantsSavedSearches`: zwei Mandanten mit je einer gespeicherten Suche, `List` bei Mandant B liefert real nur die eigene, nicht die von Mandant A | +| 3 | Test: Löschen einer gespeicherten Suche entfernt nur diese | **bestanden** – `TestDelete_RemovesOnlyThatSavedSearch`: zwei gespeicherte Suchen, eine gelöscht, `Get` liefert für die gelöschte real `ErrNotFound`, die andere bleibt real unverändert abrufbar | + +Zusätzlich (Akzeptanzkriterium 2, kein eingefrorener Snapshot): +`TestExecute_ReturnsCurrentResultsNotFrozenSnapshot` — Ausführung vor +einer neuen Indexierung liefert real 0 Treffer, danach real 1 Treffer, +ohne dass die gespeicherte Suche selbst verändert wurde. + +## Build/Test-Ergebnis (192.168.1.131) + +``` +go build ./... -> clean +go vet ./... -> clean +golangci-lint run ./... -> 0 issues +TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \ +TEST_MANTICORE_URL=http://127.0.0.1:9308 \ + go test ./... -p 1 -> alle Pakete bestanden, inkl. internal/savedsearch (4 Tests, neu), + keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate/ + crypto/search +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Trägt (gemeinsam mit ARC-08, SRC-02, SRC-04, SRC-05, +SRC-09) zu QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch +SRC-10 fertig ist. diff --git a/mail/internal/savedsearch/migrations/0001_mail_saved_searches.sql b/mail/internal/savedsearch/migrations/0001_mail_saved_searches.sql new file mode 100644 index 0000000..23a5287 --- /dev/null +++ b/mail/internal/savedsearch/migrations/0001_mail_saved_searches.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS mail_saved_searches ( + id BIGSERIAL PRIMARY KEY, + tenant_slug TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + query_text TEXT NOT NULL DEFAULT '', + filters JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_slug, user_id, name) +) diff --git a/mail/internal/savedsearch/store.go b/mail/internal/savedsearch/store.go new file mode 100644 index 0000000..5d21c0b --- /dev/null +++ b/mail/internal/savedsearch/store.go @@ -0,0 +1,174 @@ +// Package savedsearch implementiert SRC-08: benannte, wiederausführbare +// Suchanfragen inklusive aktiver Filter. Speichert ausschließlich die +// SUCHKRITERIEN (Suchtext + Filter), niemals Trefferlisten — Execute ruft +// bei jeder Ausführung real gegen mail/internal/search (SRC-01/SRC-03/ +// SRC-05) auf, damit stets aktuelle Treffer geliefert werden, kein +// eingefrorener Snapshot (Akzeptanzkriterium 2). Kombiniert bewusst zwei +// bereits fertige, unveränderte Pakete (Postgres-Speicherung hier, +// Ausführung über search.Client) statt eines davon zu erweitern. +// +// "Benutzer" ist bis zu einer zentralen Session-/IAM-Anbindung (Core- +// Board-Scope, nicht Bestandteil dieser Kachel) ein vom Aufrufer +// mitgegebener opaker userID-String — dieselbe Konvention wie der +// Tenant-Kontext in web/mail-search (SRC-04). +package savedsearch + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" +) + +//go:embed migrations/0001_mail_saved_searches.sql +var schemaMigration string + +// ErrNotFound wird geliefert, wenn keine gespeicherte Suche mit den +// angegebenen Bezugsdaten existiert. +var ErrNotFound = errors.New("savedsearch: gespeicherte suche nicht gefunden") + +// SavedSearch ist eine benannte, wiederausführbare Suchanfrage. +type SavedSearch struct { + ID int64 + TenantSlug string + UserID string + Name string + QueryText string + Filters []search.FacetFilter +} + +// Store persistiert gespeicherte Suchen in Postgres. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert — +// gleiches Muster wie mail/internal/dedup/indexworker (kein zentraler +// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden). +func (s *Store) EnsureSchema(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, schemaMigration); err != nil { + return fmt.Errorf("savedsearch: schema anlegen: %w", err) + } + return nil +} + +// Save legt eine gespeicherte Suche unter name an oder aktualisiert sie, +// falls unter demselben Namen für denselben Benutzer/Mandanten bereits +// eine existiert (Akzeptanzkriterium 1). +func (s *Store) Save(ctx context.Context, tenantSlug, userID, name, queryText string, filters []search.FacetFilter) (int64, error) { + filtersJSON, err := json.Marshal(filters) + if err != nil { + return 0, fmt.Errorf("savedsearch: filter serialisieren: %w", err) + } + + var id int64 + err = s.pool.QueryRow(ctx, ` + INSERT INTO mail_saved_searches (tenant_slug, user_id, name, query_text, filters) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (tenant_slug, user_id, name) DO UPDATE + SET query_text = EXCLUDED.query_text, filters = EXCLUDED.filters, updated_at = now() + RETURNING id + `, tenantSlug, userID, name, queryText, filtersJSON).Scan(&id) + if err != nil { + return 0, fmt.Errorf("savedsearch: speichern: %w", err) + } + return id, nil +} + +// List liefert alle gespeicherten Suchen eines Benutzers innerhalb eines +// Mandanten (Akzeptanzkriterium 3: je Benutzer/Mandant getrennt sichtbar). +func (s *Store) List(ctx context.Context, tenantSlug, userID string) ([]SavedSearch, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_slug, user_id, name, query_text, filters + FROM mail_saved_searches + WHERE tenant_slug = $1 AND user_id = $2 + ORDER BY name + `, tenantSlug, userID) + if err != nil { + return nil, fmt.Errorf("savedsearch: liste lesen: %w", err) + } + defer rows.Close() + + var results []SavedSearch + for rows.Next() { + item, err := scanSavedSearch(rows) + if err != nil { + return nil, err + } + results = append(results, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("savedsearch: liste iterieren: %w", err) + } + return results, nil +} + +// Get liefert eine einzelne gespeicherte Suche, streng auf Mandant und +// Benutzer beschränkt. +func (s *Store) Get(ctx context.Context, tenantSlug, userID string, id int64) (SavedSearch, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, tenant_slug, user_id, name, query_text, filters + FROM mail_saved_searches + WHERE tenant_slug = $1 AND user_id = $2 AND id = $3 + `, tenantSlug, userID, id) + item, err := scanSavedSearch(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return SavedSearch{}, ErrNotFound + } + return SavedSearch{}, err + } + return item, nil +} + +// Delete entfernt GENAU EINE gespeicherte Suche, streng auf Mandant und +// Benutzer beschränkt (Akzeptanzkriterium/Pflichtprüfung 3: Löschen +// entfernt nur diese eine gespeicherte Suche). +func (s *Store) Delete(ctx context.Context, tenantSlug, userID string, id int64) error { + tag, err := s.pool.Exec(ctx, ` + DELETE FROM mail_saved_searches WHERE tenant_slug = $1 AND user_id = $2 AND id = $3 + `, tenantSlug, userID, id) + if err != nil { + return fmt.Errorf("savedsearch: löschen: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanSavedSearch(row rowScanner) (SavedSearch, error) { + var item SavedSearch + var filtersJSON []byte + if err := row.Scan(&item.ID, &item.TenantSlug, &item.UserID, &item.Name, &item.QueryText, &filtersJSON); err != nil { + return SavedSearch{}, fmt.Errorf("savedsearch: zeile lesen: %w", err) + } + if err := json.Unmarshal(filtersJSON, &item.Filters); err != nil { + return SavedSearch{}, fmt.Errorf("savedsearch: filter parsen: %w", err) + } + return item, nil +} + +// Execute führt eine gespeicherte Suche LIVE gegen client aus +// (Akzeptanzkriterium 2: aktuelle Treffer, kein eingefrorener Snapshot — +// Execute speichert selbst nichts zwischen, jeder Aufruf fragt Manticore +// neu ab). Hinterlegte Filter werden über search.Client.SearchWithFilters +// tatsächlich auf die Treffermenge angewandt (UND-Verknüpfung), nicht nur +// auf ihr Vorhandensein geprüft. +func Execute(ctx context.Context, client *search.Client, saved SavedSearch) ([]search.Result, error) { + return client.SearchWithFilters(ctx, saved.TenantSlug, saved.QueryText, saved.Filters) +} diff --git a/mail/internal/savedsearch/store_test.go b/mail/internal/savedsearch/store_test.go new file mode 100644 index 0000000..f662e03 --- /dev/null +++ b/mail/internal/savedsearch/store_test.go @@ -0,0 +1,202 @@ +// Integrationstest (SRC-08): echte Postgres- UND Manticore-Instanz, +// folgt derselben TEST_*-Env-Konvention wie mail/internal/search. +package savedsearch + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" +) + +func setupStore(t *testing.T) *Store { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(func() { pool.Close() }) + + store := NewStore(pool) + if err := store.EnsureSchema(ctx); err != nil { + t.Fatalf("schema: %v", err) + } + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM mail_saved_searches WHERE tenant_slug LIKE 'mandant-src08-%'`) + }) + return store +} + +func setupSearchClient(t *testing.T) *search.Client { + t.Helper() + baseURL := os.Getenv("TEST_MANTICORE_URL") + if baseURL == "" { + t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen") + } + client := search.NewClient(baseURL) + if err := client.EnsureSchema(context.Background()); err != nil { + t.Fatalf("search-schema: %v", err) + } + return client +} + +// TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly ist die +// geforderte Pflichtprüfung 1: gespeicherte Suche mit mehreren Filtern +// wird korrekt reproduziert. +func TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly(t *testing.T) { + store := setupStore(t) + searchClient := setupSearchClient(t) + ctx := context.Background() + tenant := "mandant-src08-filter" + + docs := []search.Document{ + {MessageID: "msg-sv-treffer", Subject: "Quartalsbericht", Sender: "alice@example.com", Mailbox: "inbox"}, + {MessageID: "msg-sv-falscher-sender", Subject: "Quartalsbericht", Sender: "bob@example.com", Mailbox: "inbox"}, + {MessageID: "msg-sv-falsche-mailbox", Subject: "Quartalsbericht", Sender: "alice@example.com", Mailbox: "archiv"}, + } + for _, d := range docs { + d.TenantSlug = tenant + d.ID = search.DocumentID(tenant, d.MessageID) + if err := searchClient.Index(ctx, d); err != nil { + t.Fatalf("index %s: %v", d.MessageID, err) + } + } + + filters := []search.FacetFilter{ + {Field: search.FieldSender, Value: "alice@example.com"}, + {Field: search.FieldMailbox, Value: "inbox"}, + } + id, err := store.Save(ctx, tenant, "user-1", "Alice Inbox Quartalsbericht", "Quartalsbericht", filters) + if err != nil { + t.Fatalf("save: %v", err) + } + + saved, err := store.Get(ctx, tenant, "user-1", id) + if err != nil { + t.Fatalf("get: %v", err) + } + + results, err := Execute(ctx, searchClient, saved) + if err != nil { + t.Fatalf("execute: %v", err) + } + if len(results) != 1 || results[0].MessageID != "msg-sv-treffer" { + t.Fatalf("erwartete genau msg-sv-treffer, habe: %+v", results) + } +} + +// TestExecute_ReturnsCurrentResultsNotFrozenSnapshot deckt +// Akzeptanzkriterium 2 ab: aktuelle Treffer, kein eingefrorener Snapshot. +func TestExecute_ReturnsCurrentResultsNotFrozenSnapshot(t *testing.T) { + store := setupStore(t) + searchClient := setupSearchClient(t) + ctx := context.Background() + tenant := "mandant-src08-live" + + docID := search.DocumentID(tenant, "msg-live-1") + // Verteidigung gegen Testreste eines vorherigen Laufs (Manticore wird + // zwischen Testläufen nicht automatisch geleert, anders als Postgres + // über t.Cleanup) — Ausgangszustand muss real leer sein, damit der + // Test die Aussage "kein eingefrorener Snapshot" beweiskräftig prüft. + _ = searchClient.Delete(ctx, docID) + t.Cleanup(func() { _ = searchClient.Delete(context.Background(), docID) }) + + id, err := store.Save(ctx, tenant, "user-1", "Alles zu Zylotharion", "Zylotharion", nil) + if err != nil { + t.Fatalf("save: %v", err) + } + saved, err := store.Get(ctx, tenant, "user-1", id) + if err != nil { + t.Fatalf("get: %v", err) + } + + before, err := Execute(ctx, searchClient, saved) + if err != nil { + t.Fatalf("execute (vorher): %v", err) + } + if len(before) != 0 { + t.Fatalf("erwartete 0 treffer vor indexierung, habe %d", len(before)) + } + + if err := searchClient.Index(ctx, search.Document{ + ID: docID, + TenantSlug: tenant, + MessageID: "msg-live-1", + Subject: "Neuigkeiten zu Zylotharion", + }); err != nil { + t.Fatalf("index: %v", err) + } + + after, err := Execute(ctx, searchClient, saved) + if err != nil { + t.Fatalf("execute (nachher): %v", err) + } + if len(after) != 1 { + t.Fatalf("erwartete 1 aktuellen treffer nach neuer indexierung (kein eingefrorener snapshot), habe %d", len(after)) + } +} + +// TestList_UserSeesNoOtherTenantsSavedSearches ist die geforderte +// Pflichtprüfung 2: Benutzer sieht keine gespeicherten Suchen anderer +// Mandanten. +func TestList_UserSeesNoOtherTenantsSavedSearches(t *testing.T) { + store := setupStore(t) + ctx := context.Background() + tenantA := "mandant-src08-iso-a" + tenantB := "mandant-src08-iso-b" + + if _, err := store.Save(ctx, tenantA, "user-1", "Suche A", "irgendwas", nil); err != nil { + t.Fatalf("save mandant a: %v", err) + } + if _, err := store.Save(ctx, tenantB, "user-1", "Suche B", "irgendwas", nil); err != nil { + t.Fatalf("save mandant b: %v", err) + } + + listB, err := store.List(ctx, tenantB, "user-1") + if err != nil { + t.Fatalf("list mandant b: %v", err) + } + if len(listB) != 1 || listB[0].Name != "Suche B" { + t.Fatalf("mandant b sieht fremde/fehlende suchen: %+v", listB) + } +} + +// TestDelete_RemovesOnlyThatSavedSearch ist die geforderte Pflichtprüfung +// 3: Löschen einer gespeicherten Suche entfernt nur diese. +func TestDelete_RemovesOnlyThatSavedSearch(t *testing.T) { + store := setupStore(t) + ctx := context.Background() + tenant := "mandant-src08-delete" + + idA, err := store.Save(ctx, tenant, "user-1", "Suche A", "a", nil) + if err != nil { + t.Fatalf("save a: %v", err) + } + idB, err := store.Save(ctx, tenant, "user-1", "Suche B", "b", nil) + if err != nil { + t.Fatalf("save b: %v", err) + } + + if err := store.Delete(ctx, tenant, "user-1", idA); err != nil { + t.Fatalf("delete a: %v", err) + } + + if _, err := store.Get(ctx, tenant, "user-1", idA); err != ErrNotFound { + t.Fatalf("erwartete ErrNotFound für gelöschte suche a, habe: %v", err) + } + stillThere, err := store.Get(ctx, tenant, "user-1", idB) + if err != nil { + t.Fatalf("suche b sollte weiterhin existieren: %v", err) + } + if stillThere.Name != "Suche B" { + t.Fatalf("unerwarteter inhalt für suche b: %+v", stillThere) + } +} diff --git a/mail/internal/search/facets.go b/mail/internal/search/facets.go index 4082f46..5e7256d 100644 --- a/mail/internal/search/facets.go +++ b/mail/internal/search/facets.go @@ -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 {