package storage import ( "context" "fmt" "archivdms/internal/index" ) // SearchResultDoc is a single search hit: the full document row (re-hydrated // from Postgres, the source of truth) plus its BM25 relevance score from the // Manticore index. Document is embedded so the JSON shape stays identical to // the /api/documents list response, with an added top-level "score" field. type SearchResultDoc struct { Document Score float64 `json:"score"` } // SearchResult is the paginated envelope returned by SearchDocuments. type SearchResult struct { Results []SearchResultDoc `json:"results"` Total int `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` } // ErrSearchUnavailable is returned by SearchDocuments when no search index is // wired into the store (empty Manticore DSN). The handler maps this to a 503 — // unlike the best-effort write-path sync helpers, a search request must fail // loudly rather than silently return an empty result. var ErrSearchUnavailable = ErrNoIndexer // SearchDocuments runs a full-text + attribute query against the tenant's // Manticore index, then re-hydrates the matching document rows from Postgres // (authoritative), preserving the index's relevance ordering and attaching each // hit's score. // // The index only ever returns documents.id + score; the WHERE tenant_id / and // deleted_at IS NULL clause below is the authoritative ownership + soft-delete // boundary — the index is treated as a hint, never as the source of truth. // Returns ErrSearchUnavailable when no indexer is configured. func (s *Store) SearchDocuments(ctx context.Context, tenantID int64, q index.SearchQuery) (*SearchResult, error) { if s.indexer == nil { return nil, ErrSearchUnavailable } page := q.Page if page <= 0 { page = 1 } pageSize := q.PageSize if pageSize <= 0 { pageSize = 20 } q.Page = page q.PageSize = pageSize hits, total, err := s.indexer.ForTenant(tenantID).Search(ctx, q) if err != nil { return nil, fmt.Errorf("storage: search documents: %w", err) } result := &SearchResult{ Results: []SearchResultDoc{}, Total: total, Page: page, PageSize: pageSize, } if len(hits) == 0 { return result, nil } // Collect the hit ids (preserving score order) for a single batch SELECT. ids := make([]int64, len(hits)) scoreByID := make(map[int64]float64, len(hits)) for i, h := range hits { ids[i] = h.ID scoreByID[h.ID] = h.Score } rows, err := s.db.Query(ctx, ` SELECT id, tenant_id, title, COALESCE(doc_type, ''), COALESCE(correspondent, ''), doc_type_id, correspondent_id, storage_path, content_hash, COALESCE(ocr_text, ''), retain_until, COALESCE(source, ''), COALESCE(source_ref, ''), created_at, updated_at FROM documents WHERE id = ANY($1) AND tenant_id = $2 AND deleted_at IS NULL `, ids, tenantID) if err != nil { return nil, fmt.Errorf("storage: search hydrate documents: %w", err) } defer rows.Close() docByID := make(map[int64]Document, len(hits)) for rows.Next() { var d Document if err := rows.Scan(&d.ID, &d.TenantID, &d.Title, &d.DocType, &d.Correspondent, &d.DocTypeID, &d.CorrespondentID, &d.StoragePath, &d.ContentHash, &d.OCRText, &d.RetainUntil, &d.Source, &d.SourceRef, &d.CreatedAt, &d.UpdatedAt); err != nil { return nil, fmt.Errorf("storage: scan search document: %w", err) } docByID[d.ID] = d } if err := rows.Err(); err != nil { return nil, fmt.Errorf("storage: search rows: %w", err) } // Emit in the index's ranking order; skip ids the DB dropped (a stale index // entry for a since-deleted / re-tenanted document). for _, id := range ids { d, ok := docByID[id] if !ok { continue } result.Results = append(result.Results, SearchResultDoc{Document: d, Score: scoreByID[id]}) } return result, nil }