Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
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.
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user