package search import ( "context" "strings" "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/query" ) type Service struct { db *pgxpool.Pool } func NewService(db *pgxpool.Pool) *Service { return &Service{db: db} } type Result struct { Type string `json:"type"` ID string `json:"id"` Title string `json:"title"` Snippet string `json:"snippet"` Date any `json:"date,omitempty"` } type SearchResponse struct { Query string `json:"query"` Results []Result `json:"results"` Count int `json:"count"` Pagination query.PaginationMeta `json:"pagination,omitempty"` } func (s *Service) Search(ctx context.Context, externalID, q, typesRaw string, params query.ListParams) (SearchResponse, error) { typeList := parseTypes(typesRaw) tsQuery := toTSQuery(q) allResults := make([]Result, 0) for _, t := range typeList { switch t { case "mail": mailResults, err := s.searchMail(ctx, externalID, tsQuery, params) if err != nil { return SearchResponse{}, err } allResults = append(allResults, mailResults...) case "contacts": contactResults, err := s.searchContacts(ctx, q, params) if err != nil { return SearchResponse{}, err } allResults = append(allResults, contactResults...) case "events": // Events are in Nextcloud CalDAV, not in PG - skip for now. } } total := int64(len(allResults)) page, _ := paginateResults(allResults, params.Offset(), params.Limit()) return SearchResponse{ Query: q, Results: page, Count: len(page), Pagination: params.Meta(&total), }, nil } func parseTypes(typesRaw string) []string { if strings.TrimSpace(typesRaw) == "" { return []string{"mail", "contacts", "events"} } parts := strings.Split(typesRaw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if t := strings.TrimSpace(p); t != "" { out = append(out, t) } } return out } func paginateResults(results []Result, offset, limit int) ([]Result, int64) { total := int64(len(results)) if offset >= len(results) { return []Result{}, total } end := offset + limit if end > len(results) { end = len(results) } return results[offset:end], total } func (s *Service) searchMail(ctx context.Context, externalID, tsQuery string, params query.ListParams) ([]Result, error) { limit := params.Offset() + params.Limit() if limit < 20 { limit = 20 } rows, err := s.db.Query(ctx, ` SELECT m.id, m.subject, m.snippet, m.date, ts_rank(m.search_vector, to_tsquery('simple', $1)) as rank FROM messages m JOIN mail_accounts ma ON m.account_id = ma.id WHERE ma.user_id = (SELECT id FROM users WHERE external_id = $2) AND m.search_vector @@ to_tsquery('simple', $1) ORDER BY rank DESC LIMIT $3 `, tsQuery, externalID, limit) if err != nil { return nil, err } defer rows.Close() results := make([]Result, 0) for rows.Next() { var id, subject, snippet string var date any var rank float64 if err := rows.Scan(&id, &subject, &snippet, &date, &rank); err != nil { return nil, err } results = append(results, Result{ Type: "mail", ID: id, Title: subject, Snippet: snippet, Date: date, }) } if err := rows.Err(); err != nil { return nil, err } return results, nil } func (s *Service) searchContacts(ctx context.Context, queryText string, params query.ListParams) ([]Result, error) { limit := params.Offset() + params.Limit() if limit < 10 { limit = 10 } rows, err := s.db.Query(ctx, ` SELECT id, name, email FROM users WHERE (name ILIKE '%' || $1 || '%' OR email ILIKE '%' || $1 || '%') LIMIT $2 `, queryText, limit) if err != nil { return nil, err } defer rows.Close() results := make([]Result, 0) for rows.Next() { var id, name, email string if err := rows.Scan(&id, &name, &email); err != nil { return nil, err } results = append(results, Result{ Type: "contact", ID: id, Title: name, Snippet: email, }) } if err := rows.Err(); err != nil { return nil, err } return results, nil } func toTSQuery(input string) string { words := strings.Fields(input) for i, w := range words { words[i] = w + ":*" } return strings.Join(words, " & ") }