feat(search): align conversation result payloads
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user