Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
460 lines
15 KiB
Go
460 lines
15 KiB
Go
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
|
|
}
|