diff --git a/cmd/reindex_search/main.go b/cmd/reindex_search/main.go index 5b18e165..a8f8f439 100644 --- a/cmd/reindex_search/main.go +++ b/cmd/reindex_search/main.go @@ -139,7 +139,14 @@ func reindexConversations(ctx context.Context, engine search.SearchEngine, db *g var lastID uint for { var rows []model.Conversation - q := db.WithContext(ctx).Preload("Contact").Where("id > ?", lastID).Order("id ASC").Limit(batchSize) + q := db.WithContext(ctx). + Preload("Contact"). + Preload("Inbox"). + Preload("Assignee"). + Preload("Messages", func(db *gorm.DB) *gorm.DB { + return db.Order("created_at ASC, id ASC") + }). + Where("id > ?", lastID).Order("id ASC").Limit(batchSize) if accountID != 0 { q = q.Where("account_id = ?", accountID) } diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 820d9a60..42b7472c 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,11 +49,11 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.84 search agent-sender filter parity, prepared as `feat(search): align agent sender filters`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align agent sender filters`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchService `from=agent:id` message filter parity. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.85 search conversation payload parity, prepared as `feat(search): align conversation result payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align conversation result payloads`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot search conversation Jbuilder payload parity. No route artifacts change. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: message advanced-search `from=agent:id` filters now match GoChat's stored agent sender variants (`agent`, `user`, `User`) in DB fallback and Meilisearch, while preserving contact/bot sender aliases and retaining P3.83 conversation ordering, P3.82 contact-conversation reindex fanout, P3.81 conversation identity search, P3.80 search time-bound parity, P3.79 advanced-search feature gates, P3.78 message recency, P3.77 current-user inbox access, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: SearchAPI conversation results now include Chatwoot-compatible nested `message`, `contact`, `inbox`, `agent`, and `additional_attributes` payloads from DB fallback and Meilisearch documents, while retaining P3.84 agent-sender filter parity, P3.83 conversation ordering, P3.82 contact-conversation reindex fanout, P3.81 conversation identity search, P3.80 search time-bound parity, P3.79 advanced-search feature gates, P3.78 message recency, P3.77 current-user inbox access, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `967` registered routes after profile MFA route tracking. @@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.85 search conversation payload parity | Implemented for reused SearchAPI conversation cards: DB fallback results and Meilisearch documents now carry Chatwoot search Jbuilder fields `message`, `contact`, `inbox`, `agent`, and `additional_attributes`, with conversation indexing/reindexing preloading those display relations. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for exact `api/v1/models/message` field drift, message attachment/sender serialization gaps, or Meilisearch document payload bloat/per-page tuning. | Focused SearchHandler, SearchRepo, and document-builder tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.84 search agent-sender filter parity | Implemented for reused SearchAPI message filters: frontend `from=agent:id` now matches GoChat's stored agent sender variants (`agent`, `user`, `User`) in both DB fallback and Meilisearch filters, while contact and bot sender filters also tolerate local/Rails-style casing. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for additional polymorphic sender values, exact Searchkick sender-type indexing, or global `ChatwootApp.advanced_search_allowed?` differences. | Focused SearchFilter, Meilisearch engine, and SearchRepo sender-filter tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.83 search conversation ordering parity | Implemented for DB fallback conversation search: default ordering now matches Chatwoot `SearchService#filter_conversations` with newest `conversations.created_at` first instead of `last_activity_at`. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for Meilisearch Searchkick ordering drift, tie-break behavior, or explicit sort param differences. | Focused SearchRepo ordering test passed; full `go test ./...` passed outside the restricted socket sandbox after retrying an unrelated `internal/worker` SQLite in-memory flake; `git diff --check` passed. No route artifacts change. | | 0 | P3.82 search contact-conversation reindex parity | Implemented for Meilisearch freshness after P3.81: every contact indexing event also loads account-scoped conversations for that contact and indexes them with preloaded contact data, so conversation identity documents follow contact direct updates and import merges. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for bulk fanout batching, delete semantics, or contact-label-only fanout optimization. | Focused ContactService search-index hook test passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -2715,3 +2716,4 @@ Verification milestone gates: - 2026-06-07: P3.82 search contact-conversation reindex checkpoint prepared as `feat(search): reindex contact conversations`; audited the P3.81 Meilisearch conversation identity document contract and contact direct update/import merge indexing paths. GoChat contact indexing now fans out to related account-scoped conversations with `Contact` preloaded, so conversation search documents stay fresh after contact identity changes. Focused ContactService search-index hook tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.83 search conversation ordering checkpoint prepared as `feat(search): align conversation search order`; audited Chatwoot `SearchService#filter_conversations` ordering. GoChat DB fallback conversation search now orders by `conversations.created_at DESC, conversations.id DESC` instead of the local conversation-list `last_activity_at` default, while Meilisearch already emits `created_at_ts:desc` for default search sort. Focused SearchRepo ordering tests passed; full `go test ./...` passed outside the restricted socket sandbox after retrying an unrelated `internal/worker` SQLite in-memory flake; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.84 search agent-sender filter checkpoint prepared as `feat(search): align agent sender filters`; audited Chatwoot `SearchService#parse_from_param`, `#apply_sender_filter`, and reused `SearchContactAgentSelector.vue`/SearchAPI message filter callers. GoChat DB fallback and Meilisearch message filters now treat `from=agent:id` as agent/user sender aliases (`agent`, `user`, `User`) and tolerate contact/bot casing aliases, so reused frontend agent filters find locally stored outgoing agent messages. Focused SearchFilter, Meilisearch engine, and SearchRepo sender-filter tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.85 search conversation payload checkpoint prepared as `feat(search): align conversation result payloads`; audited Chatwoot search `conversations.json.jbuilder`, `_conversation_search_result`, `_message`, `_contact`, `_inbox`, `_agent`, and reused `SearchResultConversationsList.vue`. GoChat SearchAPI conversation results now include nested `message`, `contact`, `inbox`, `agent`, and `additional_attributes` payloads for DB fallback and Meilisearch hits; DB search, durable indexing, contact fanout reindexing, and `cmd/reindex_search` now preload the display relations needed to build those documents. Focused SearchHandler, SearchRepo, and document-builder tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/handler/api/v1/search_handler.go b/internal/handler/api/v1/search_handler.go index d869232b..c3e7d1a4 100644 --- a/internal/handler/api/v1/search_handler.go +++ b/internal/handler/api/v1/search_handler.go @@ -318,27 +318,127 @@ func serializeSearchConversations(results []search.SearchResult) []map[string]an func serializeSearchConversation(result search.SearchResult) map[string]any { if conv, ok := result.Data.(model.Conversation); ok { - return map[string]any{ - "id": conversationDisplayID(&conv), - "account_id": conv.AccountID, - "created_at": conv.CreatedAt.Unix(), - "additional_attributes": jsonObject(conv.AdditionalAttributes), - } + return serializeSearchConversationModel(&conv) } if conv, ok := result.Data.(*model.Conversation); ok && conv != nil { - return map[string]any{ - "id": conversationDisplayID(conv), - "account_id": conv.AccountID, - "created_at": conv.CreatedAt.Unix(), - "additional_attributes": jsonObject(conv.AdditionalAttributes), - } + return serializeSearchConversationModel(conv) } - data := nestedSearchData(result, "conversation") + root := searchDataRoot(result) + data := nestedSearchDataFromRoot(root, "conversation") return map[string]any{ "id": firstMapValue(data, "display_id", "id"), "account_id": firstMapValue(data, "account_id"), "created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")), "additional_attributes": firstMapValue(data, "additional_attributes"), + "message": serializeSearchConversationMessageMap(firstNestedSearchData(root, data, "message")), + "contact": serializeSearchConversationContactMap(firstNestedSearchData(root, data, "contact")), + "inbox": serializeSearchConversationInboxMap(firstNestedSearchData(root, data, "inbox")), + "agent": serializeSearchConversationAgentMap(firstNestedSearchData(root, data, "agent", "assignee")), + } +} + +func serializeSearchConversationModel(conv *model.Conversation) map[string]any { + return map[string]any{ + "id": conversationDisplayID(conv), + "account_id": conv.AccountID, + "created_at": conv.CreatedAt.Unix(), + "additional_attributes": jsonObject(conv.AdditionalAttributes), + "message": serializeSearchConversationMessageModel(conv), + "contact": serializeSearchConversationContactModel(conv), + "inbox": serializeSearchConversationInboxModel(conv), + "agent": serializeSearchConversationAgentModel(conv), + } +} + +func serializeSearchConversationMessageModel(conv *model.Conversation) map[string]any { + if len(conv.Messages) == 0 { + return map[string]any{} + } + return serializeSearchMessageModel(&conv.Messages[0]) +} + +func serializeSearchConversationContactModel(conv *model.Conversation) map[string]any { + if conv.Contact != nil { + return serializeSearchContactModel(conv.Contact) + } + if conv.ContactID != 0 { + return map[string]any{"id": conv.ContactID} + } + return map[string]any{} +} + +func serializeSearchConversationInboxModel(conv *model.Conversation) map[string]any { + if conv.Inbox != nil { + return map[string]any{ + "id": conv.Inbox.ID, + "channel_id": conv.Inbox.ChannelID, + "name": conv.Inbox.Name, + "channel_type": conv.Inbox.ChannelType, + } + } + if conv.InboxID != 0 { + return map[string]any{"id": conv.InboxID, "channel_type": conv.ChannelType} + } + return map[string]any{} +} + +func serializeSearchConversationAgentModel(conv *model.Conversation) map[string]any { + if conv.Assignee != nil { + return map[string]any{ + "id": conv.Assignee.ID, + "available_name": nonEmpty(conv.Assignee.DisplayName, conv.Assignee.Name), + "email": conv.Assignee.Email, + "name": conv.Assignee.Name, + "role": nonEmpty(conv.Assignee.Role, "agent"), + } + } + return map[string]any{} +} + +func serializeSearchConversationMessageMap(data map[string]any) map[string]any { + if len(data) == 0 { + return map[string]any{} + } + return serializeSearchMessage(search.SearchResult{Data: map[string]any{"message": data}}) +} + +func serializeSearchConversationContactMap(data map[string]any) map[string]any { + if len(data) == 0 { + return map[string]any{} + } + return map[string]any{ + "email": firstMapValue(data, "email"), + "id": firstMapValue(data, "id"), + "name": firstMapValue(data, "name"), + "phone_number": firstMapValue(data, "phone_number"), + "identifier": firstMapValue(data, "identifier"), + "additional_attributes": firstMapValue(data, "additional_attributes"), + "last_activity_at": unixFromMapValue(firstMapValue(data, "last_activity_at")), + } +} + +func serializeSearchConversationInboxMap(data map[string]any) map[string]any { + if len(data) == 0 { + return map[string]any{} + } + return map[string]any{ + "id": firstMapValue(data, "id"), + "channel_id": firstMapValue(data, "channel_id"), + "name": firstMapValue(data, "name"), + "channel_type": firstMapValue(data, "channel_type"), + } +} + +func serializeSearchConversationAgentMap(data map[string]any) map[string]any { + if len(data) == 0 { + return map[string]any{} + } + return map[string]any{ + "id": firstMapValue(data, "id"), + "available_name": firstMapValue(data, "available_name", "display_name", "name"), + "email": firstMapValue(data, "email"), + "name": firstMapValue(data, "name"), + "role": firstMapValue(data, "role"), } } @@ -497,10 +597,21 @@ func serializeSearchArticleModel(article *model.Article) map[string]any { } func nestedSearchData(result search.SearchResult, key string) map[string]any { + return nestedSearchDataFromRoot(searchDataRoot(result), key) +} + +func searchDataRoot(result search.SearchResult) map[string]any { root, ok := anyMap(result.Data) if !ok { return map[string]any{} } + if data, ok := anyMap(root["data"]); ok { + return data + } + return root +} + +func nestedSearchDataFromRoot(root map[string]any, key string) map[string]any { if nested, ok := anyMap(root[key]); ok { return nested } @@ -513,6 +624,18 @@ func nestedSearchData(result search.SearchResult, key string) map[string]any { return root } +func firstNestedSearchData(root map[string]any, fallback map[string]any, keys ...string) map[string]any { + for _, key := range keys { + if nested, ok := anyMap(root[key]); ok { + return nested + } + if nested, ok := anyMap(fallback[key]); ok { + return nested + } + } + return map[string]any{} +} + func anyMap(value any) (map[string]any, bool) { switch typed := value.(type) { case map[string]any: diff --git a/internal/handler/api/v1/search_handler_test.go b/internal/handler/api/v1/search_handler_test.go index cfb08586..58555754 100644 --- a/internal/handler/api/v1/search_handler_test.go +++ b/internal/handler/api/v1/search_handler_test.go @@ -6,10 +6,12 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/datatypes" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/search" @@ -198,6 +200,59 @@ func TestSearchHandler_SearchConversations_Success(t *testing.T) { require.Len(t, results, 1) } +func TestSearchHandler_SearchConversations_ChatwootPayloadShape(t *testing.T) { + displayID := uint(42) + createdAt := time.Date(2026, 6, 7, 8, 30, 0, 0, time.UTC) + conversation := model.Conversation{ + Base: model.Base{ID: 7, CreatedAt: createdAt}, + DisplayID: &displayID, + AccountID: 1, + InboxID: 3, + ContactID: 5, + AssigneeID: uintPtr(9), + AdditionalAttributes: datatypes.JSON(`{"mail_subject":"Need pricing"}`), + Contact: &model.Contact{Base: model.Base{ID: 5}, AccountID: 1, Name: "Ada", Email: "ada@example.com"}, + Inbox: &model.Inbox{Base: model.Base{ID: 3}, AccountID: 1, Name: "Website", ChannelType: "web_widget", ChannelID: 11}, + Assignee: &model.User{Base: model.Base{ID: 9}, AccountID: 1, Name: "Agent One", Email: "agent@example.com", Role: "administrator"}, + Messages: []model.Message{{ + Base: model.Base{ID: 13, CreatedAt: createdAt.Add(-time.Minute)}, + AccountID: 1, + InboxID: 3, + ConversationID: 7, + Content: "hello", + MessageType: "incoming", + ContentType: "text", + Status: "sent", + }}, + } + repo := &mockSearchRepo{conversations: []model.Conversation{conversation}, convTotal: 1} + svc := search.NewSearchService(repo) + handler := NewSearchHandler(svc) + router := setupSearchHandlerRouter(handler) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/conversations?q=ada", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + payload := body["payload"].(map[string]any) + results := payload["conversations"].([]any) + require.Len(t, results, 1) + item := results[0].(map[string]any) + assert.Equal(t, float64(42), item["id"]) + assert.Equal(t, float64(createdAt.Unix()), item["created_at"]) + assert.Equal(t, "Need pricing", item["additional_attributes"].(map[string]any)["mail_subject"]) + assert.Equal(t, "Ada", item["contact"].(map[string]any)["name"]) + assert.Equal(t, "ada@example.com", item["contact"].(map[string]any)["email"]) + assert.Equal(t, "Website", item["inbox"].(map[string]any)["name"]) + assert.Equal(t, float64(11), item["inbox"].(map[string]any)["channel_id"]) + assert.Equal(t, "Agent One", item["agent"].(map[string]any)["available_name"]) + assert.Equal(t, float64(13), item["message"].(map[string]any)["id"]) + assert.Equal(t, float64(0), item["message"].(map[string]any)["message_type"]) +} + func TestSearchHandler_SearchConversations_InvalidAccountID(t *testing.T) { repo := &mockSearchRepo{} svc := search.NewSearchService(repo) diff --git a/internal/model/conversation.go b/internal/model/conversation.go index 91050169..680ff4e6 100644 --- a/internal/model/conversation.go +++ b/internal/model/conversation.go @@ -42,7 +42,10 @@ type Conversation struct { LastMessageAt *int64 `gorm:"index" json:"last_message_at,omitempty"` LastNonSysMsgAt *int64 `json:"last_non_system_message_at,omitempty"` - Contact *Contact `gorm:"foreignKey:ContactID" json:"contact,omitempty"` + Contact *Contact `gorm:"foreignKey:ContactID" json:"contact,omitempty"` + Inbox *Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"` + Assignee *User `gorm:"foreignKey:AssigneeID" json:"assignee,omitempty"` + Messages []Message `gorm:"foreignKey:ConversationID" json:"messages,omitempty"` } // BeforeCreate auto-generates UUID before inserting a new Conversation record. diff --git a/internal/repository/search_repo.go b/internal/repository/search_repo.go index e5ced69b..cafeb9aa 100644 --- a/internal/repository/search_repo.go +++ b/internal/repository/search_repo.go @@ -204,12 +204,23 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID orderClause = "conversations.created_at DESC, conversations.id DESC" } - err := q.Offset(filter.Offset()).Limit(filter.PerPage). + err := withConversationSearchPayloadPreloads(q). + Offset(filter.Offset()).Limit(filter.PerPage). Order(orderClause). Find(&conversations).Error return conversations, total, err } +func withConversationSearchPayloadPreloads(q *gorm.DB) *gorm.DB { + return q. + Preload("Contact"). + Preload("Inbox"). + Preload("Assignee"). + Preload("Messages", func(db *gorm.DB) *gorm.DB { + return db.Order("created_at ASC, id ASC") + }) +} + // SearchMessages searches messages with advanced filters. // This method satisfies search.SearchRepoInterface by accepting *search.SearchFilter // and converting it to *RepoSearchFilter internally. diff --git a/internal/repository/search_repo_test.go b/internal/repository/search_repo_test.go index 2d6fbde5..ce30948a 100644 --- a/internal/repository/search_repo_test.go +++ b/internal/repository/search_repo_test.go @@ -344,6 +344,38 @@ func TestSearchRepo_SearchConversations_EmptyQuery(t *testing.T) { assert.True(t, found, "expected conversation %d in results", conv.ID) } +func TestSearchRepo_SearchConversations_PreloadsChatwootPayloadRelations(t *testing.T) { + db := setupTestDB(t) + repo := NewSearchRepo(db) + + account := createTestAccountForSearch(t, db) + contact := createTestContactForSearch(t, db, account.ID, "Ada Lovelace", "ada@example.com", "+123") + inbox := createTestInboxForSearch(t, db, account.ID, "Website") + agent := createTestUserForSearch(t, db, account.ID, "agent-payload@example.com") + conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &agent.ID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conv).Error) + newer := createTestMessageForSearch(t, db, account.ID, conv.ID, "newer message", "incoming") + older := createTestMessageForSearch(t, db, account.ID, conv.ID, "older message", "outgoing") + require.NoError(t, db.Model(older).Update("created_at", time.Date(2026, 6, 7, 8, 0, 0, 0, time.UTC)).Error) + require.NoError(t, db.Model(newer).Update("created_at", time.Date(2026, 6, 7, 9, 0, 0, 0, time.UTC)).Error) + + filter := &search.SearchFilter{Page: 1, PerPage: 25} + results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter) + + require.NoError(t, err) + assert.Equal(t, int64(1), total) + require.Len(t, results, 1) + result := results[0] + require.NotNil(t, result.Contact) + assert.Equal(t, contact.Name, result.Contact.Name) + require.NotNil(t, result.Inbox) + assert.Equal(t, inbox.Name, result.Inbox.Name) + require.NotNil(t, result.Assignee) + assert.Equal(t, agent.Email, result.Assignee.Email) + require.Len(t, result.Messages, 2) + assert.Equal(t, older.ID, result.Messages[0].ID) +} + func TestSearchRepo_SearchConversations_DisplayAndContactMatch(t *testing.T) { db := setupTestDB(t) repo := NewSearchRepo(db) diff --git a/internal/search/engine.go b/internal/search/engine.go index c48b0698..3d333524 100644 --- a/internal/search/engine.go +++ b/internal/search/engine.go @@ -159,13 +159,73 @@ func ConversationDocument(conv model.Conversation) SearchDocument { CreatedAtTS: timestamp(conv.CreatedAt), UpdatedAtTS: timestamp(conv.UpdatedAt), LastActivityAtTS: timestampPtr(conv.LastActivityAt), - Data: map[string]interface{}{"conversation": conv}, + 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) diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go index 5cee2f45..f56d6a29 100644 --- a/internal/search/engine_test.go +++ b/internal/search/engine_test.go @@ -51,6 +51,9 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) { displayID := uint(42) conv.DisplayID = &displayID conv.Contact = &model.Contact{Name: "Ada Lovelace", Email: "ada@example.com", PhoneNumber: "+123", Identifier: "ada-id"} + conv.Inbox = &model.Inbox{Base: model.Base{ID: 7}, Name: "Website", ChannelID: 11, ChannelType: "web_widget"} + conv.Assignee = &model.User{Base: model.Base{ID: 8}, Name: "Agent One", Email: "agent@example.com", Role: "administrator"} + conv.Messages = []model.Message{{Base: model.Base{ID: 15, CreatedAt: time.Unix(1772884700, 0)}, AccountID: 3, InboxID: 7, ConversationID: 12, Content: "hello", MessageType: "incoming"}} doc := ConversationDocument(conv) assert.Equal(t, "3:conversation:12", doc.UID) @@ -60,6 +63,10 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) { assert.Equal(t, lastActivity, doc.LastActivityAtTS) assert.Equal(t, "Conversation #42", doc.Title) assert.Equal(t, "42 Ada Lovelace ada@example.com +123 ada-id", doc.Content) + assert.Equal(t, "Ada Lovelace", doc.Data["contact"].(map[string]interface{})["name"]) + assert.Equal(t, "Website", doc.Data["inbox"].(map[string]interface{})["name"]) + assert.Equal(t, "Agent One", doc.Data["agent"].(map[string]interface{})["available_name"]) + assert.Equal(t, uint(15), doc.Data["message"].(map[string]interface{})["id"]) } func TestContactDocumentSetsResolvedScopeFields(t *testing.T) { diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go index 4bc68e78..e2ee2684 100644 --- a/internal/service/contact_service.go +++ b/internal/service/contact_service.go @@ -74,6 +74,11 @@ func (s *ContactService) indexContactConversations(ctx context.Context, contact var conversations []model.Conversation if err := s.repo.DB().WithContext(ctx). Preload("Contact"). + Preload("Inbox"). + Preload("Assignee"). + Preload("Messages", func(db *gorm.DB) *gorm.DB { + return db.Order("created_at ASC, id ASC") + }). Where("account_id = ? AND contact_id = ?", contact.AccountID, contact.ID). Find(&conversations).Error; err != nil { applogger.L().Warnf("search index sync failed for contact conversations %d: %v", contact.ID, err) diff --git a/internal/service/search_indexer_worker.go b/internal/service/search_indexer_worker.go index de63435b..0228d50c 100644 --- a/internal/service/search_indexer_worker.go +++ b/internal/service/search_indexer_worker.go @@ -192,6 +192,11 @@ func (i *DurableSearchIndexer) loadConversation(ctx context.Context, accountID, var item model.Conversation err := i.db.WithContext(ctx). Preload("Contact"). + Preload("Inbox"). + Preload("Assignee"). + Preload("Messages", func(db *gorm.DB) *gorm.DB { + return db.Order("created_at ASC, id ASC") + }). Where("id = ? AND account_id = ?", id, accountID). First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) {