SRC-05: facetten-filter-api

Facetten- und Filter-API für Suche (Absender, Zeitraum, Postfach,
Anhangstyp, Tag), mandantengetrennt, mit UND-Verknüpfung mehrerer Filter.

- migrations/0002..0005: vier nummerierte ALTER-Migrationen für die neuen
  Facettenfelder (Manticore erlaubt nur eine Spalte je ALTER-Anweisung),
  EnsureSchema wendet sie idempotent nach.
- fields.go: FacetFields-Whitelist, einzige zulässige Facettendimensionen.
- facets.go: Client.Facets nutzt Manticores strukturierte aggs.terms/
  aggs.range-API, kein dynamischer SQL-Klauselbau. Filter kombinieren als
  zusätzliche equals-Klauseln in derselben bool.must-Liste wie der
  Tenant-Filter. Zeitraum-Facette über feste Buckets via aggs.range.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-05-PRUEFPROTOKOLL.md):
1. TestFacets_CountsMatchActualHits: Facettenzahlen stimmen real mit der
   tatsächlichen Treffermenge überein.
2. TestFacets_ThreeFiltersCombineWithAND: 3 kombinierte Filter schränken
   4 Dokumente real auf genau 1 verbleibenden Treffer ein.
3. TestFacets_TenantSeparation: Facetten eines Mandanten enthalten real
   keine Werte eines anderen.

Kein Umbau: Search/Delete/Index-Verhalten aus SRC-01/SRC-03 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 10:49:52 +02:00
co-authored by Claude Sonnet 5
parent d23438d3d0
commit db73aab0de
9 changed files with 413 additions and 6 deletions
+56 -6
View File
@@ -22,6 +22,29 @@ import (
//go:embed migrations/0001_mail_documents.sql
var schemaMigration string
// SRC-05: Facettenfelder als eigene, nummerierte ALTER-Migrationen
// nachgezogen (Manticore erlaubt nur eine Spalte je ALTER TABLE ADD
// COLUMN-Anweisung). Reihenfolge ist die Anwendungsreihenfolge.
//
//go:embed migrations/0002_mail_documents_facets.sql
var migrationAddSender string
//go:embed migrations/0003_mail_documents_mailbox.sql
var migrationAddMailbox string
//go:embed migrations/0004_mail_documents_attachment_type.sql
var migrationAddAttachmentType string
//go:embed migrations/0005_mail_documents_tag.sql
var migrationAddTag string
var facetMigrations = []string{
migrationAddSender,
migrationAddMailbox,
migrationAddAttachmentType,
migrationAddTag,
}
// Client spricht ausschließlich über die strukturierte Manticore-HTTP-
// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go).
// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort
@@ -40,24 +63,46 @@ func NewClient(baseURL string) *Client {
}
// EnsureSchema legt den Index gemäß dem versionierten, statischen
// Migrationstext an (Akzeptanzkriterium 1). Idempotent (CREATE TABLE
// IF NOT EXISTS im Migrationstext).
// Migrationstext an (Akzeptanzkriterium 1) und zieht die Facettenfelder
// (SRC-05) idempotent nach.
func (c *Client) EnsureSchema(ctx context.Context) error {
form := "query=" + schemaMigration
if err := c.runSchemaSQL(ctx, schemaMigration); err != nil {
return fmt.Errorf("search: schema anlegen: %w", err)
}
for _, migration := range facetMigrations {
if err := c.runSchemaSQL(ctx, migration); err != nil {
// Manticore meldet bei erneutem ADD COLUMN "field already in
// schema" — kein Fehler, sondern der bereits angewendete
// Migrationsschritt (Idempotenz, gleiche CREATE-TABLE-IF-NOT-
// EXISTS-Konvention wie das Basisschema).
if strings.Contains(err.Error(), "already in schema") {
continue
}
return fmt.Errorf("search: facettenfeld-migration: %w", err)
}
}
return nil
}
func (c *Client) runSchemaSQL(ctx context.Context, query string) error {
form := "query=" + query
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form))
if err != nil {
return fmt.Errorf("search: schema-anfrage bauen: %w", err)
return fmt.Errorf("anfrage bauen: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("search: schema anlegen: %w", err)
return fmt.Errorf("ausführen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("search: schema anlegen, status %d: %s", resp.StatusCode, string(body))
return fmt.Errorf("status %d: %s", resp.StatusCode, string(body))
}
if strings.Contains(string(body), `"error":"`) && !strings.Contains(string(body), `"error":""`) {
return fmt.Errorf("%s", string(body))
}
return nil
}
@@ -72,6 +117,11 @@ type Document struct {
Body string `json:"body"`
AttachmentText string `json:"attachment_text"`
SentAtUnixEpoch int64 `json:"sent_at"`
// Facettenfelder (SRC-05), optional — leerer String bedeutet "kein Wert".
Sender string `json:"sender"`
Mailbox string `json:"mailbox"`
AttachmentType string `json:"attachment_type"`
Tag string `json:"tag"`
}
// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff