package search import ( "context" "fmt" "net/http" "sort" "strings" "time" "github.com/go-resty/resty/v2" ) type MeiliSearchEngine struct { client *resty.Client indexPrefix string } type meiliSearchResponse struct { Hits []map[string]interface{} `json:"hits"` EstimatedTotalHits int64 `json:"estimatedTotalHits"` TotalHits int64 `json:"totalHits"` } func NewMeiliSearchEngine(cfg EngineConfig) *MeiliSearchEngine { cfg = normalizeEngineConfig(cfg) client := resty.New().SetBaseURL(strings.TrimRight(cfg.Host, "/")) if cfg.APIKey != "" { client.SetAuthToken(cfg.APIKey) } if cfg.TimeoutSeconds > 0 { client.SetTimeout(time.Duration(cfg.TimeoutSeconds) * time.Second) } return &MeiliSearchEngine{client: client, indexPrefix: cfg.IndexPrefix} } func (e *MeiliSearchEngine) Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) { if filter == nil { filter = &SearchFilter{Page: 1, PerPage: DefaultPerPage, SortBy: DefaultSortBy, SortOrder: DefaultSortOrder} } if filter.Page < 1 { filter.Page = 1 } if filter.PerPage < 1 { filter.PerPage = DefaultPerPage } results := make([]SearchResult, 0) byType := map[string]int64{} var total int64 for _, docType := range searchableTypes(filter) { resp, err := e.searchIndex(ctx, docType, accountID, query, filter) if err != nil { return nil, err } count := resp.EstimatedTotalHits if count == 0 && resp.TotalHits > 0 { count = resp.TotalHits } byType[string(docType)] = count total += count for _, hit := range resp.Hits { results = append(results, hitToSearchResult(docType, hit)) } } sort.SliceStable(results, func(i, j int) bool { if results[i].Score == results[j].Score { return results[i].ID > results[j].ID } return results[i].Score > results[j].Score }) return &SearchResponse{ Results: results, TotalCount: total, ByType: byType, Page: filter.Page, PerPage: filter.PerPage, Query: strings.TrimSpace(query), }, nil } func (e *MeiliSearchEngine) IndexDocument(ctx context.Context, doc SearchDocument) error { doc.ensureUID() resp, err := e.client.R(). SetContext(ctx). SetBody([]SearchDocument{doc}). Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(doc.Type))) return meiliError(resp, err, "index document") } func (e *MeiliSearchEngine) IndexBatch(ctx context.Context, docs []SearchDocument) error { grouped := map[SearchResultType][]SearchDocument{} for _, doc := range docs { doc.ensureUID() grouped[doc.Type] = append(grouped[doc.Type], doc) } for docType, batch := range grouped { resp, err := e.client.R(). SetContext(ctx). SetBody(batch). Post(fmt.Sprintf("/indexes/%s/documents", e.indexName(docType))) if err := meiliError(resp, err, "index batch"); err != nil { return err } } return nil } func (e *MeiliSearchEngine) DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error { resp, err := e.client.R(). SetContext(ctx). Delete(fmt.Sprintf("/indexes/%s/documents/%s", e.indexName(docType), documentUID(docType, accountID, id))) return meiliError(resp, err, "delete document") } func (e *MeiliSearchEngine) Bootstrap(ctx context.Context) error { for _, docType := range searchableTypes(nil) { if err := e.ensureIndex(ctx, docType); err != nil { return err } if err := e.applySettings(ctx, docType); err != nil { return err } } return nil } func (e *MeiliSearchEngine) Close() error { return nil } func (e *MeiliSearchEngine) searchIndex(ctx context.Context, docType SearchResultType, accountID uint, query string, filter *SearchFilter) (*meiliSearchResponse, error) { body := map[string]interface{}{ "q": strings.TrimSpace(query), "offset": filter.Offset(), "limit": filter.PerPage, "filter": e.filterExpression(accountID, docType, filter), "showRankingScore": true, } if sortExpr := sortExpression(filter); sortExpr != "" { body["sort"] = []string{sortExpr} } var out meiliSearchResponse resp, err := e.client.R(). SetContext(ctx). SetBody(body). SetResult(&out). Post(fmt.Sprintf("/indexes/%s/search", e.indexName(docType))) if err := meiliError(resp, err, "search"); err != nil { return nil, err } return &out, nil } func (e *MeiliSearchEngine) ensureIndex(ctx context.Context, docType SearchResultType) error { uid := e.indexName(docType) resp, err := e.client.R().SetContext(ctx).Get(fmt.Sprintf("/indexes/%s", uid)) if err != nil { return err } if resp.StatusCode() != http.StatusNotFound { return meiliError(resp, nil, "check index") } resp, err = e.client.R(). SetContext(ctx). SetBody(map[string]interface{}{"uid": uid, "primaryKey": "uid"}). Post("/indexes") return meiliError(resp, err, "create index") } func (e *MeiliSearchEngine) applySettings(ctx context.Context, docType SearchResultType) error { settings := map[string]interface{}{ "searchableAttributes": []string{"title", "content", "snippet", "status", "priority", "labels", "locale"}, "filterableAttributes": []string{"account_id", "type", "status", "priority", "message_type", "sender_type", "sender_id", "content_type", "private", "contact_source", "labels", "assignee_id", "team_id", "inbox_id", "contact_id", "conversation_id", "portal_id", "locale", "created_at_ts", "updated_at_ts"}, "sortableAttributes": []string{"created_at_ts", "updated_at_ts", "id"}, } resp, err := e.client.R(). SetContext(ctx). SetBody(settings). Patch(fmt.Sprintf("/indexes/%s/settings", e.indexName(docType))) return meiliError(resp, err, "apply settings") } func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResultType, filter *SearchFilter) string { parts := []string{fmt.Sprintf("account_id = %d", accountID)} if filter == nil { return strings.Join(parts, " AND ") } if docType == ResultTypeConversation { parts = appendListFilter(parts, "status", filter.Status) parts = appendListFilter(parts, "priority", filter.Priority) if filter.AssigneeID != nil { parts = append(parts, fmt.Sprintf("assignee_id = %d", *filter.AssigneeID)) } if filter.TeamID != nil { parts = append(parts, fmt.Sprintf("team_id = %d", *filter.TeamID)) } parts = appendListFilter(parts, "labels", filter.Labels) } if filter.InboxID != nil { parts = append(parts, fmt.Sprintf("inbox_id = %d", *filter.InboxID)) } if docType == ResultTypeMessage { if filter.MessageType != "" { parts = append(parts, fmt.Sprintf("message_type = %q", filter.MessageType)) } if filter.SenderType != "" { parts = append(parts, fmt.Sprintf("sender_type = %q", filter.SenderType)) } if filter.SenderID != nil { parts = append(parts, fmt.Sprintf("sender_id = %d", *filter.SenderID)) } if filter.ContentType != "" { parts = append(parts, fmt.Sprintf("content_type = %q", filter.ContentType)) } if filter.Private != nil { parts = append(parts, fmt.Sprintf("private = %t", *filter.Private)) } } if docType == ResultTypeContact && filter.ContactSource != "" { parts = append(parts, fmt.Sprintf("contact_source = %q", filter.ContactSource)) } if docType == ResultTypeArticle || docType == ResultTypeHelpCenter { if filter.PortalID != nil { parts = append(parts, fmt.Sprintf("portal_id = %d", *filter.PortalID)) } if filter.ArticleStatus != "" { parts = append(parts, fmt.Sprintf("status = %q", filter.ArticleStatus)) } if filter.ArticleLocale != "" { parts = append(parts, fmt.Sprintf("locale = %q", filter.ArticleLocale)) } } if filter.DateFrom != nil { parts = append(parts, fmt.Sprintf("created_at_ts >= %d", filter.DateFrom.Unix())) } if filter.DateTo != nil { parts = append(parts, fmt.Sprintf("created_at_ts <= %d", filter.DateTo.Unix())) } return strings.Join(parts, " AND ") } func (e *MeiliSearchEngine) indexName(docType SearchResultType) string { return e.indexPrefix + strings.ReplaceAll(string(docType), "_", "_") + "s" } func appendListFilter(parts []string, field string, values []string) []string { if len(values) == 0 { return parts } quoted := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value != "" { quoted = append(quoted, fmt.Sprintf("%s = %q", field, value)) } } if len(quoted) > 0 { parts = append(parts, "("+strings.Join(quoted, " OR ")+")") } return parts } func sortExpression(filter *SearchFilter) string { if filter == nil { return "" } field := filter.SortBy switch field { case "", "created", "created_at": field = "created_at_ts" case "updated", "updated_at": field = "updated_at_ts" case "id": field = "id" default: return "" } dir := filter.SortOrder if dir != "asc" { dir = "desc" } return field + ":" + dir } func hitToSearchResult(docType SearchResultType, hit map[string]interface{}) SearchResult { result := SearchResult{Type: docType, Data: hit} if v, ok := hit["type"].(string); ok && v != "" { result.Type = SearchResultType(v) } result.ID = uintFromHit(hit["id"]) result.AccountID = uintFromHit(hit["account_id"]) if snippet, ok := hit["snippet"].(string); ok { result.Snippet = snippet } else if title, ok := hit["title"].(string); ok { result.Snippet = title } if score, ok := hit["_rankingScore"].(float64); ok { result.Score = score } else { result.Score = 1 } return result } func uintFromHit(v interface{}) uint { switch n := v.(type) { case float64: return uint(n) case int: return uint(n) case int64: return uint(n) case uint: return n default: return 0 } } func meiliError(resp *resty.Response, err error, action string) error { if err != nil { return fmt.Errorf("meilisearch %s: %w", action, err) } if resp == nil { return nil } if resp.StatusCode() >= 400 { return fmt.Errorf("meilisearch %s: status=%d body=%s", action, resp.StatusCode(), string(resp.Body())) } return nil }