Files
gochat/internal/search/engine.go
T

249 lines
7.9 KiB
Go

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"`
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,
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
}