package search import ( "context" "encoding/json" "fmt" "path/filepath" "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"` ContactType string `json:"contact_type,omitempty"` ContactHasDetails bool `json:"contact_has_details"` 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"` LastActivityAtTS int64 `json:"last_activity_at_ts,omitempty"` 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 timestampPtr(value *int64) int64 { if value == nil { return 0 } return *value } func ConversationDocument(conv model.Conversation) SearchDocument { inboxID := conv.InboxID content := conversationSearchContent(conv) doc := SearchDocument{ ID: conv.ID, Type: ResultTypeConversation, AccountID: conv.AccountID, Title: conversationSearchTitle(conv), Content: content, 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), LastActivityAtTS: timestampPtr(conv.LastActivityAt), Data: conversationSearchData(conv), } doc.Snippet = conversationSnippet(&conv, "") doc.ensureUID() return doc } func conversationSearchData(conv model.Conversation) map[string]interface{} { data := map[string]interface{}{"conversation": conv} if conv.Contact != nil { data["contact"] = map[string]interface{}{ "email": conv.Contact.Email, "id": conv.Contact.ID, "name": conv.Contact.Name, "phone_number": conv.Contact.PhoneNumber, "identifier": conv.Contact.Identifier, "additional_attributes": conv.Contact.AdditionalAttributes, "last_activity_at": timestampPtr(conv.Contact.LastActivityAt), } } if conv.Inbox != nil { data["inbox"] = map[string]interface{}{ "id": conv.Inbox.ID, "channel_id": conv.Inbox.ChannelID, "name": conv.Inbox.Name, "channel_type": conv.Inbox.ChannelType, } } if conv.Assignee != nil { data["agent"] = map[string]interface{}{ "id": conv.Assignee.ID, "available_name": firstNonEmpty(conv.Assignee.DisplayName, conv.Assignee.Name), "email": conv.Assignee.Email, "name": conv.Assignee.Name, "role": firstNonEmpty(conv.Assignee.Role, "agent"), } } if len(conv.Messages) > 0 { message := conv.Messages[0] data["message"] = map[string]interface{}{ "id": message.ID, "content": message.Content, "account_id": message.AccountID, "inbox_id": message.InboxID, "conversation_id": message.ConversationID, "message_type": message.MessageType, "content_type": message.ContentType, "status": message.Status, "content_attributes": message.ContentAttributes, "additional_attributes": message.AdditionalAttributes, "created_at": timestamp(message.CreatedAt), "private": message.Private, "source_id": message.SourceID, } } return data } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func conversationSearchTitle(conv model.Conversation) string { if conv.DisplayID != nil && *conv.DisplayID != 0 { return fmt.Sprintf("Conversation #%d", *conv.DisplayID) } return fmt.Sprintf("Conversation #%d", conv.ID) } func conversationSearchContent(conv model.Conversation) string { parts := make([]string, 0, 5) if conv.DisplayID != nil && *conv.DisplayID != 0 { parts = append(parts, fmt.Sprintf("%d", *conv.DisplayID)) } if conv.Contact != nil { parts = append(parts, conv.Contact.Name, conv.Contact.Email, conv.Contact.PhoneNumber, conv.Contact.Identifier) } return strings.TrimSpace(strings.Join(parts, " ")) } func MessageDocument(msg model.Message) SearchDocument { inboxID := msg.InboxID conversationID := msg.ConversationID messageData := messageDocumentData(msg) 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": messageData}, } doc.ensureUID() return doc } func messageDocumentData(msg model.Message) map[string]interface{} { conversationID := msg.ConversationID if msg.Conversation != nil { conversationID = messageConversationDisplayID(msg.Conversation) } data := map[string]interface{}{ "id": msg.ID, "content": msg.Content, "account_id": msg.AccountID, "inbox_id": msg.InboxID, "conversation_id": conversationID, "message_type": msg.MessageType, "content_type": firstNonEmpty(msg.ContentType, "text"), "status": firstNonEmpty(msg.Status, "sent"), "content_attributes": jsonObject(msg.ContentAttributes), "created_at": timestamp(msg.CreatedAt), "private": msg.Private, "source_id": msg.SourceID, } if msg.EchoID != "" { data["echo_id"] = msg.EchoID } if len(msg.Attachments) > 0 { attachments := make([]map[string]interface{}, 0, len(msg.Attachments)) for i := range msg.Attachments { attachments = append(attachments, attachmentDocumentData(msg.Attachments[i])) } data["attachments"] = attachments } return data } func messageConversationDisplayID(conversation *model.Conversation) uint { if conversation != nil && conversation.DisplayID != nil && *conversation.DisplayID != 0 { return *conversation.DisplayID } if conversation != nil { return conversation.ID } return 0 } func attachmentDocumentData(attachment model.Attachment) map[string]interface{} { dataURL := firstNonEmpty(attachment.FileURL, attachment.ExternalURL) data := map[string]interface{}{ "id": attachment.ID, "message_id": attachment.MessageID, "file_type": attachment.FileType, "account_id": attachment.AccountID, "data_url": dataURL, "thumb_url": attachment.ThumbURL, "file_size": attachment.FileSize, "extension": strings.TrimPrefix(filepath.Ext(attachment.FileName), "."), "width": attachment.Width, "height": attachment.Height, } if attachment.FileType == "audio" { data["transcribed_text"] = metadataValue(attachment.Metadata, "transcribed_text") } return data } func jsonObject(raw []byte) map[string]interface{} { if len(raw) == 0 || string(raw) == "null" { return map[string]interface{}{} } var out map[string]interface{} if err := json.Unmarshal(raw, &out); err != nil || out == nil { return map[string]interface{}{} } return out } func metadataValue(raw string, key string) string { if strings.TrimSpace(raw) == "" { return "" } var data map[string]interface{} if err := json.Unmarshal([]byte(raw), &data); err != nil { return "" } if value, ok := data[key].(string); ok { return value } return "" } func ContactDocument(contact model.Contact) SearchDocument { content := strings.TrimSpace(strings.Join([]string{contact.Email, contact.PhoneNumber, contact.Identifier}, " ")) doc := SearchDocument{ ID: contact.ID, Type: ResultTypeContact, AccountID: contact.AccountID, Title: contact.Name, Content: content, Snippet: contactSnippet(&contact, ""), ContactSource: contact.ContactType, ContactType: contact.ContactType, ContactHasDetails: content != "", Labels: contact.Labels, CreatedAtTS: timestamp(contact.CreatedAt), UpdatedAtTS: timestamp(contact.UpdatedAt), LastActivityAtTS: timestampPtr(contact.LastActivityAt), 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 articleData := articleDocumentData(article) 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": articleData}, } doc.ensureUID() return doc } func articleDocumentData(article model.Article) map[string]interface{} { portalSlug := "" if article.Portal.Slug != "" { portalSlug = article.Portal.Slug } categoryName := "" if article.Category != nil && article.Category.Name != "" { categoryName = article.Category.Name } return map[string]interface{}{ "id": article.ID, "title": article.Title, "locale": article.Locale, "content": article.Content, "slug": article.Slug, "portal_slug": portalSlug, "account_id": article.AccountID, "category_name": categoryName, "status": article.Status, "updated_at": timestamp(article.UpdatedAt), } } 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 }