ultisuite-backend/internal/search/service.go

181 lines
4.2 KiB
Go

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, " & ")
}