package search import ( "context" "fmt" "strings" "time" "github.com/gochat/gochat/internal/model" ) const ( EngineMeilisearch = "meilisearch" EngineDB = "db" ) // EngineConfig is the search package's stable configuration boundary. The app // layer maps internal/config.SearchConfig into this struct. type EngineConfig struct { Engine string Host string APIKey string IndexPrefix string TimeoutSeconds int } // SearchEngine hides the full-text backend behind one contract. Meilisearch is // the production parity engine; DB implementations are local development only. type SearchEngine interface { Search(ctx context.Context, accountID uint, query string, filter *SearchFilter) (*SearchResponse, error) IndexDocument(ctx context.Context, doc SearchDocument) error IndexBatch(ctx context.Context, docs []SearchDocument) error DeleteDocument(ctx context.Context, docType SearchResultType, accountID uint, id uint) error Bootstrap(ctx context.Context) error Close() error } // SearchDocument is the normalized payload stored in Meilisearch. Data keeps the // entity payload available for Chatwoot-compatible frontend responses. type SearchDocument struct { UID string `json:"uid"` ID uint `json:"id"` Type SearchResultType `json:"type"` AccountID uint `json:"account_id"` Title string `json:"title,omitempty"` Content string `json:"content,omitempty"` Snippet string `json:"snippet,omitempty"` Status string `json:"status,omitempty"` Priority string `json:"priority,omitempty"` MessageType string `json:"message_type,omitempty"` SenderType string `json:"sender_type,omitempty"` SenderID *uint `json:"sender_id,omitempty"` ContentType string `json:"content_type,omitempty"` Private bool `json:"private"` ContactSource string `json:"contact_source,omitempty"` Labels []string `json:"labels,omitempty"` AssigneeID *uint `json:"assignee_id,omitempty"` TeamID *uint `json:"team_id,omitempty"` InboxID *uint `json:"inbox_id,omitempty"` ContactID *uint `json:"contact_id,omitempty"` ConversationID *uint `json:"conversation_id,omitempty"` PortalID *uint `json:"portal_id,omitempty"` Locale string `json:"locale,omitempty"` CreatedAtTS int64 `json:"created_at_ts"` UpdatedAtTS int64 `json:"updated_at_ts"` Data map[string]interface{} `json:"data,omitempty"` } func (d *SearchDocument) ensureUID() { if d.UID == "" { d.UID = documentUID(d.Type, d.AccountID, d.ID) } } func documentUID(docType SearchResultType, accountID uint, id uint) string { return fmt.Sprintf("%d:%s:%d", accountID, docType, id) } func normalizeEngineConfig(cfg EngineConfig) EngineConfig { cfg.Engine = strings.ToLower(strings.TrimSpace(cfg.Engine)) if cfg.Engine == "" { cfg.Engine = EngineMeilisearch } if cfg.Host == "" { cfg.Host = "http://localhost:7700" } if cfg.IndexPrefix == "" { cfg.IndexPrefix = "gochat_" } if cfg.TimeoutSeconds == 0 { cfg.TimeoutSeconds = 5 } return cfg } // NewSearchEngine builds the configured search backend. func NewSearchEngine(cfg EngineConfig, fallbackRepo SearchRepoInterface) (SearchEngine, error) { cfg = normalizeEngineConfig(cfg) switch cfg.Engine { case EngineMeilisearch: return NewMeiliSearchEngine(cfg), nil case EngineDB: if fallbackRepo == nil { return nil, fmt.Errorf("db search engine requires a fallback repository") } return NewSearchEngineDB(fallbackRepo), nil default: return nil, fmt.Errorf("unsupported search engine %q", cfg.Engine) } } func searchableTypes(filter *SearchFilter) []SearchResultType { if filter != nil && len(filter.Types) > 0 { return filter.Types } return []SearchResultType{ ResultTypeConversation, ResultTypeMessage, ResultTypeContact, ResultTypeCompany, ResultTypeArticle, ResultTypeHelpCenter, } } func timestamp(t time.Time) int64 { if t.IsZero() { return 0 } return t.Unix() } func ConversationDocument(conv model.Conversation) SearchDocument { inboxID := conv.InboxID doc := SearchDocument{ ID: conv.ID, Type: ResultTypeConversation, AccountID: conv.AccountID, Title: fmt.Sprintf("Conversation #%d", conv.ID), Content: conv.Labels, Status: conv.Status, Priority: conv.Priority, Labels: splitLabels(conv.Labels), AssigneeID: conv.AssigneeID, TeamID: conv.TeamID, InboxID: &inboxID, ContactID: &conv.ContactID, CreatedAtTS: timestamp(conv.CreatedAt), UpdatedAtTS: timestamp(conv.UpdatedAt), Data: map[string]interface{}{"conversation": conv}, } doc.Snippet = conversationSnippet(&conv, "") doc.ensureUID() return doc } func MessageDocument(msg model.Message) SearchDocument { inboxID := msg.InboxID conversationID := msg.ConversationID doc := SearchDocument{ ID: msg.ID, Type: ResultTypeMessage, AccountID: msg.AccountID, Title: fmt.Sprintf("Message #%d", msg.ID), Content: msg.Content, Snippet: messageSnippet(&msg, ""), Status: msg.Status, MessageType: msg.MessageType, SenderType: msg.SenderType, SenderID: msg.SenderID, ContentType: msg.ContentType, Private: msg.Private, InboxID: &inboxID, ConversationID: &conversationID, CreatedAtTS: timestamp(msg.CreatedAt), UpdatedAtTS: timestamp(msg.UpdatedAt), Data: map[string]interface{}{"message": msg}, } doc.ensureUID() return doc } func ContactDocument(contact model.Contact) SearchDocument { doc := SearchDocument{ ID: contact.ID, Type: ResultTypeContact, AccountID: contact.AccountID, Title: contact.Name, Content: strings.TrimSpace(strings.Join([]string{contact.Email, contact.PhoneNumber, contact.Identifier}, " ")), Snippet: contactSnippet(&contact, ""), ContactSource: contact.ContactType, CreatedAtTS: timestamp(contact.CreatedAt), UpdatedAtTS: timestamp(contact.UpdatedAt), Data: map[string]interface{}{"contact": contact}, } doc.ensureUID() return doc } func CompanyDocument(company model.Company) SearchDocument { doc := SearchDocument{ ID: company.ID, Type: ResultTypeCompany, AccountID: company.AccountID, Title: company.Name, Content: strings.TrimSpace(strings.Join([]string{company.Description, company.Domain, company.WebsiteURL}, " ")), Snippet: company.Name, CreatedAtTS: timestamp(company.CreatedAt), UpdatedAtTS: timestamp(company.UpdatedAt), Data: map[string]interface{}{"company": company}, } doc.ensureUID() return doc } func ArticleDocument(article model.Article) SearchDocument { portalID := article.PortalID doc := SearchDocument{ ID: article.ID, Type: ResultTypeArticle, AccountID: article.AccountID, Title: article.Title, Content: strings.TrimSpace(strings.Join([]string{article.Description, article.Content}, " ")), Snippet: articleSnippet(&article, ""), Status: article.Status, PortalID: &portalID, Locale: article.Locale, CreatedAtTS: timestamp(article.CreatedAt), UpdatedAtTS: timestamp(article.UpdatedAt), Data: map[string]interface{}{"article": article}, } doc.ensureUID() return doc } func splitLabels(raw string) []string { if raw == "" { return nil } raw = strings.Trim(raw, "[]") parts := strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == '"' || r == '\'' }) labels := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { labels = append(labels, part) } } return labels }