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
+174
View File
@@ -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)
}