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
175 lines
6.0 KiB
Go
175 lines
6.0 KiB
Go
// 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)
|
|
}
|