diff --git a/mail/docs/SRC-05-PRUEFPROTOKOLL.md b/mail/docs/SRC-05-PRUEFPROTOKOLL.md new file mode 100644 index 0000000..b68d0b3 --- /dev/null +++ b/mail/docs/SRC-05-PRUEFPROTOKOLL.md @@ -0,0 +1,54 @@ +# SRC-05 – Prüfprotokoll: Facetten- & Filter-API + +Voraussetzung SRC-03 (Fertig). + +## Umsetzung + +- `mail/internal/search/migrations/0002..0005_*.sql`: vier eigene, + nummerierte `ALTER TABLE ADD COLUMN`-Migrationen für die neuen + Facettenfelder (`sender`, `mailbox`, `attachment_type`, `tag`) — Manticore + erlaubt nur eine Spalte je ALTER-Anweisung. `EnsureSchema` wendet sie + idempotent nach (Fehlertext `"already in schema"` gilt als bereits + angewendet, kein Fehlerzustand). +- `fields.go`: neue statische Feldkonstanten + `FacetFields`-Whitelist + (`sender`, `mailbox`, `attachment_type`, `tag`) — einzige Quelle + zulässiger Facettendimensionen, kein beliebiger Client-Feldname möglich. +- `facets.go` — `Client.Facets(ctx, tenantSlug, queryText, filters)`: + nutzt Manticores strukturierte `aggs.terms`/`aggs.range`-API (kein + dynamischer SQL-Klauselbau). Tenant-Filter + optionale + `FacetFilter`-Liste laufen als zusätzliche `equals`-Klauseln in + derselben `bool.must`-Liste (Akzeptanzkriterium 2: UND-Verknüpfung). + Zeitraum-Facette über feste Buckets (letzte 7 Tage/30 Tage/Jahr/älter) + via `aggs.range` auf `sent_at`. +- `Document` um optionale Facettenfelder erweitert (`Sender`, `Mailbox`, + `AttachmentType`, `Tag`). +- Kein Umbau: `Search`/`Delete`/`Index`-Verhalten aus SRC-01/SRC-03 + unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/ + `encstorage` unverändert. + +## Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Test: Facettenzahlen stimmen mit tatsächlicher Treffermenge überein | **bestanden** – `TestFacets_CountsMatchActualHits`: 3 reale Dokumente indexiert, Facette `sender` liefert real `alice@example.com`→2, `bob@example.com`→1, Facette `attachment_type` liefert real `pdf`→2 | +| 2 | Test: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer | **bestanden** – `TestFacets_ThreeFiltersCombineWithAND`: 4 Dokumente, von denen 3 je genau einen der drei Filter (Sender/Postfach/Anhangstyp) verletzen — nach Kombination aller drei Filter bleibt real genau 1 Treffer übrig | +| 3 | Test: Facetten eines Mandanten enthalten keine Werte eines anderen | **bestanden** – `TestFacets_TenantSeparation`: identische Feldstruktur bei zwei Mandanten, Facette bei Mandant B enthält real keinen Wert von Mandant A | + +## 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/search (10 Tests, + keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate) +``` + +## Gesamtergebnis + +**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen +real erfüllt. Entsperrt SRC-06, trägt (gemeinsam mit ARC-08, SRC-02, +SRC-04, SRC-08, SRC-09, SRC-10) zu QA-03 bei — QA-03 bleibt weiterhin +blockiert, bis auch die übrigen vier Tickets fertig sind. diff --git a/mail/internal/search/client.go b/mail/internal/search/client.go index 245f218..e444e9d 100644 --- a/mail/internal/search/client.go +++ b/mail/internal/search/client.go @@ -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 diff --git a/mail/internal/search/facets.go b/mail/internal/search/facets.go new file mode 100644 index 0000000..4082f46 --- /dev/null +++ b/mail/internal/search/facets.go @@ -0,0 +1,180 @@ +// SRC-05: Facetten- & Filter-API. Nutzt Manticores strukturierte +// Aggregations-API (aggs.terms/aggs.range) — keine dynamische +// SQL-Klauselbildung, dieselbe Konvention wie Search/Delete (fields.go). +package search + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// FacetFilter schränkt Suche/Facettenberechnung auf einen bereits +// gewählten Facettenwert ein. Field MUSS aus FacetFields stammen — +// Facets liefert einen Fehler bei jedem anderen Wert (verhindert einen +// beliebigen, vom Aufrufer bestimmten Feldnamen in der Anfrage). +type FacetFilter struct { + Field string + Value string +} + +// FacetValue ist ein einzelner Facettenwert mit Trefferzahl +// (Akzeptanzkriterium 1). +type FacetValue struct { + Value string + Count int64 +} + +// DateRangeFacet ist ein Zeitraum-Bucket mit Trefferzahl. +type DateRangeFacet struct { + Label string + Count int64 +} + +// FacetResult fasst alle Facettendimensionen einer Anfrage zusammen. +type FacetResult struct { + // Values ist je FacetFields-Eintrag (sender/mailbox/attachment_type/tag) + // befüllt. + Values map[string][]FacetValue + // DateRanges sind feste Zeitraum-Buckets über FieldSentAt. + DateRanges []DateRangeFacet +} + +// farFuture ist die obere Grenze des jüngsten Zeitraum-Buckets. Manticores +// range-Aggregation verlangt für jeden Bucket ein explizites "to" — ein +// hinreichend großer fester Wert (Jahr 2100) übernimmt die Rolle von +// "unbegrenzt in die Zukunft", ohne den Feldtyp zu wechseln. +const farFuture int64 = 4102444800 + +type dateRangeBoundary struct { + label string + from int64 // 0 = ab Epoch (unbegrenzt in die Vergangenheit) + to int64 +} + +// dateRangeBoundaries berechnet die festen Zeitraum-Buckets relativ zu now +// (Parameter statt time.Now() direkt, damit Facets testbar bleibt). +func dateRangeBoundaries(now time.Time) []dateRangeBoundary { + sevenDaysAgo := now.AddDate(0, 0, -7).Unix() + thirtyDaysAgo := now.AddDate(0, 0, -30).Unix() + oneYearAgo := now.AddDate(-1, 0, 0).Unix() + return []dateRangeBoundary{ + {label: "letzte_7_tage", from: sevenDaysAgo, to: farFuture}, + {label: "letzte_30_tage", from: thirtyDaysAgo, to: sevenDaysAgo}, + {label: "letztes_jahr", from: oneYearAgo, to: thirtyDaysAgo}, + {label: "aelter", from: 0, to: oneYearAgo}, + } +} + +func isFacetField(field string) bool { + for _, f := range FacetFields { + if f == field { + return true + } + } + 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) { + must := []map[string]any{ + {"equals": map[string]any{FieldTenantSlug: tenantSlug}}, + } + if queryText != "" { + must = append(must, map[string]any{"query_string": queryText}) + } + for _, f := range filters { + if !isFacetField(f.Field) { + return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field) + } + must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}}) + } + + aggs := map[string]any{} + for _, field := range FacetFields { + aggs[field] = map[string]any{"terms": map[string]any{"field": field, "size": 100}} + } + boundaries := dateRangeBoundaries(time.Now()) + ranges := make([]map[string]any, 0, len(boundaries)) + for _, b := range boundaries { + ranges = append(ranges, map[string]any{"from": b.from, "to": b.to}) + } + aggs["sent_at"] = map[string]any{"range": map[string]any{"field": FieldSentAt, "ranges": ranges}} + + payload := map[string]any{ + "index": IndexName, + "query": map[string]any{"bool": map[string]any{"must": must}}, + "aggs": aggs, + "limit": 0, + } + body, err := json.Marshal(payload) + if err != nil { + return FacetResult{}, fmt.Errorf("search: facettenanfrage serialisieren: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body)) + if err != nil { + return FacetResult{}, fmt.Errorf("search: facettenanfrage bauen: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return FacetResult{}, fmt.Errorf("search: facetten abrufen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return FacetResult{}, fmt.Errorf("search: facetten-antwort lesen: %w", err) + } + if resp.StatusCode != http.StatusOK { + return FacetResult{}, fmt.Errorf("search: facetten, status %d: %s", resp.StatusCode, string(respBody)) + } + + var parsed facetResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + return FacetResult{}, fmt.Errorf("search: facetten-antwort parsen: %w", err) + } + + result := FacetResult{Values: make(map[string][]FacetValue, len(FacetFields))} + for _, field := range FacetFields { + bucket := parsed.Aggregations[field] + values := make([]FacetValue, 0, len(bucket.Buckets)) + for _, b := range bucket.Buckets { + if b.Key == "" { + continue + } + values = append(values, FacetValue{Value: b.Key, Count: b.DocCount}) + } + result.Values[field] = values + } + + sentAtBucket := parsed.Aggregations["sent_at"] + result.DateRanges = make([]DateRangeFacet, 0, len(boundaries)) + for i, b := range boundaries { + count := int64(0) + if i < len(sentAtBucket.Buckets) { + count = sentAtBucket.Buckets[i].DocCount + } + result.DateRanges = append(result.DateRanges, DateRangeFacet{Label: b.label, Count: count}) + } + + return result, nil +} + +type facetResponse struct { + Aggregations map[string]struct { + Buckets []struct { + Key string `json:"key"` + DocCount int64 `json:"doc_count"` + } `json:"buckets"` + } `json:"aggregations"` +} diff --git a/mail/internal/search/facets_test.go b/mail/internal/search/facets_test.go new file mode 100644 index 0000000..2908302 --- /dev/null +++ b/mail/internal/search/facets_test.go @@ -0,0 +1,107 @@ +// Integrationstest (SRC-05): echte Manticore-Instanz, TEST_MANTICORE_URL +// (gleiche Konvention wie integration_test.go/ranking_test.go). +package search + +import ( + "context" + "testing" +) + +func indexFacetDoc(t *testing.T, client *Client, ctx context.Context, tenant string, doc Document) { + t.Helper() + doc.TenantSlug = tenant + doc.ID = DocumentID(tenant, doc.MessageID) + if err := client.Index(ctx, doc); err != nil { + t.Fatalf("index %s: %v", doc.MessageID, err) + } +} + +// TestFacets_CountsMatchActualHits ist die geforderte Pflichtprüfung 1: +// Facettenzahlen stimmen mit tatsächlicher Treffermenge überein. +func TestFacets_CountsMatchActualHits(t *testing.T) { + client := setupClient(t) + ctx := context.Background() + tenant := "mandant-src05-zahlen" + + indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-1", Subject: "a", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf"}) + indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-2", Subject: "b", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx"}) + indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-fz-3", Subject: "c", Sender: "bob@example.com", Mailbox: "archiv", AttachmentType: "pdf"}) + + result, err := client.Facets(ctx, tenant, "", nil) + if err != nil { + t.Fatalf("facets: %v", err) + } + + senderCounts := toCountMap(result.Values[FieldSender]) + if senderCounts["alice@example.com"] != 2 { + t.Fatalf("erwartete 2 treffer für alice@example.com, habe %d (%+v)", senderCounts["alice@example.com"], result.Values[FieldSender]) + } + if senderCounts["bob@example.com"] != 1 { + t.Fatalf("erwartete 1 treffer für bob@example.com, habe %d", senderCounts["bob@example.com"]) + } + + attachmentCounts := toCountMap(result.Values[FieldAttachmentType]) + if attachmentCounts["pdf"] != 2 { + t.Fatalf("erwartete 2 treffer für pdf, habe %d", attachmentCounts["pdf"]) + } +} + +// TestFacets_ThreeFiltersCombineWithAND ist die geforderte Pflichtprüfung +// 2: Kombination von drei Filtern liefert korrekt eingeschränkte Treffer. +func TestFacets_ThreeFiltersCombineWithAND(t *testing.T) { + client := setupClient(t) + ctx := context.Background() + tenant := "mandant-src05-kombi" + + indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-treffer", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"}) + indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-sender", Subject: "x", Sender: "bob@example.com", Mailbox: "inbox", AttachmentType: "pdf", Tag: "wichtig"}) + indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falsche-mailbox", Subject: "x", Sender: "alice@example.com", Mailbox: "archiv", AttachmentType: "pdf", Tag: "wichtig"}) + indexFacetDoc(t, client, ctx, tenant, Document{MessageID: "msg-kombi-falscher-typ", Subject: "x", Sender: "alice@example.com", Mailbox: "inbox", AttachmentType: "docx", Tag: "wichtig"}) + + filters := []FacetFilter{ + {Field: FieldSender, Value: "alice@example.com"}, + {Field: FieldMailbox, Value: "inbox"}, + {Field: FieldAttachmentType, Value: "pdf"}, + } + result, err := client.Facets(ctx, tenant, "", filters) + if err != nil { + t.Fatalf("facets: %v", err) + } + + tagCounts := toCountMap(result.Values[FieldTag]) + if tagCounts["wichtig"] != 1 { + t.Fatalf("erwartete genau 1 verbleibenden treffer nach 3 UND-verknüpften filtern, habe %d (%+v)", tagCounts["wichtig"], result.Values[FieldTag]) + } +} + +// TestFacets_TenantSeparation ist die geforderte Pflichtprüfung 3: +// Facetten eines Mandanten enthalten keine Werte eines anderen. +func TestFacets_TenantSeparation(t *testing.T) { + client := setupClient(t) + ctx := context.Background() + tenantA := "mandant-src05-facetten-a" + tenantB := "mandant-src05-facetten-b" + + indexFacetDoc(t, client, ctx, tenantA, Document{MessageID: "msg-fa-1", Subject: "a", Sender: "nur-a@example.com", Mailbox: "inbox", AttachmentType: "pdf"}) + indexFacetDoc(t, client, ctx, tenantB, Document{MessageID: "msg-fb-1", Subject: "b", Sender: "nur-b@example.com", Mailbox: "inbox", AttachmentType: "pdf"}) + + resultB, err := client.Facets(ctx, tenantB, "", nil) + if err != nil { + t.Fatalf("facets mandant b: %v", err) + } + senderCountsB := toCountMap(resultB.Values[FieldSender]) + if _, present := senderCountsB["nur-a@example.com"]; present { + t.Fatalf("mandant b sieht facettenwert von mandant a: %+v", resultB.Values[FieldSender]) + } + if senderCountsB["nur-b@example.com"] != 1 { + t.Fatalf("erwartete eigenen facettenwert bei mandant b, habe: %+v", resultB.Values[FieldSender]) + } +} + +func toCountMap(values []FacetValue) map[string]int64 { + m := make(map[string]int64, len(values)) + for _, v := range values { + m[v.Value] = v.Count + } + return m +} diff --git a/mail/internal/search/fields.go b/mail/internal/search/fields.go index fac3762..3975cf7 100644 --- a/mail/internal/search/fields.go +++ b/mail/internal/search/fields.go @@ -24,8 +24,20 @@ const ( FieldBody = "body" FieldAttachmentText = "attachment_text" FieldSentAt = "sent_at" + // Facettenfelder (SRC-05), nachgezogen über migrations/0002..0005. + FieldSender = "sender" + FieldMailbox = "mailbox" + FieldAttachmentType = "attachment_type" + FieldTag = "tag" ) +// FacetFields sind die je Kachel unterstützten Filterdimensionen +// (Akzeptanzkriterium 1: Absender, Postfach, Anhangstyp, Tag — Zeitraum +// läuft separat über FieldSentAt als Bereichsfacette, siehe facets.go). +// Statische Liste — Aufrufer können ausschließlich diese Feldnamen als +// Facetten-/Filterdimension angeben, kein beliebiger Client-Feldname. +var FacetFields = []string{FieldSender, FieldMailbox, FieldAttachmentType, FieldTag} + // DocumentID berechnet deterministisch die Manticore-Dokument-ID aus // Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer // separat vergebenen ID, damit Re-Indexierung (Index) und Löschung diff --git a/mail/internal/search/migrations/0002_mail_documents_facets.sql b/mail/internal/search/migrations/0002_mail_documents_facets.sql new file mode 100644 index 0000000..6da9a4a --- /dev/null +++ b/mail/internal/search/migrations/0002_mail_documents_facets.sql @@ -0,0 +1 @@ +ALTER TABLE mail_documents ADD COLUMN sender string attribute indexed diff --git a/mail/internal/search/migrations/0003_mail_documents_mailbox.sql b/mail/internal/search/migrations/0003_mail_documents_mailbox.sql new file mode 100644 index 0000000..1a98b2e --- /dev/null +++ b/mail/internal/search/migrations/0003_mail_documents_mailbox.sql @@ -0,0 +1 @@ +ALTER TABLE mail_documents ADD COLUMN mailbox string attribute indexed diff --git a/mail/internal/search/migrations/0004_mail_documents_attachment_type.sql b/mail/internal/search/migrations/0004_mail_documents_attachment_type.sql new file mode 100644 index 0000000..da9e001 --- /dev/null +++ b/mail/internal/search/migrations/0004_mail_documents_attachment_type.sql @@ -0,0 +1 @@ +ALTER TABLE mail_documents ADD COLUMN attachment_type string attribute indexed diff --git a/mail/internal/search/migrations/0005_mail_documents_tag.sql b/mail/internal/search/migrations/0005_mail_documents_tag.sql new file mode 100644 index 0000000..a96f7ce --- /dev/null +++ b/mail/internal/search/migrations/0005_mail_documents_tag.sql @@ -0,0 +1 @@ +ALTER TABLE mail_documents ADD COLUMN tag string attribute indexed