// 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) }