249 lines
7.9 KiB
Go
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
|
|
}
|