feat(search): align message result payloads
This commit is contained in:
@@ -173,7 +173,10 @@ func reindexMessages(ctx context.Context, engine search.SearchEngine, db *gorm.D
|
||||
var lastID uint
|
||||
for {
|
||||
var rows []model.Message
|
||||
q := db.WithContext(ctx).Where("id > ?", lastID).Order("id ASC").Limit(batchSize)
|
||||
q := db.WithContext(ctx).
|
||||
Preload("Conversation").
|
||||
Preload("Attachments", func(db *gorm.DB) *gorm.DB { return db.Order("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 tracker reconciliation, prepared as `docs: reconcile parity tracker`.
|
||||
- Latest implementation checkpoint: `0b47066 feat(search): align conversation result payloads` closes P3.85 search conversation payload parity.
|
||||
- Latest documentation/tooling checkpoint: this tracker update reconciles stale next-slice wording, adds the P3.84/P3.85 commit ledger rows, and keeps the executable queue pointed at Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. No route artifacts change.
|
||||
- Current tracking checkpoint: 2026-06-07 P3.86 search message payload parity, prepared as `feat(search): align message result payloads`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align message result payloads`.
|
||||
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot search message Jbuilder payload parity for sender, attachments, and display-ID routing. 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: 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.
|
||||
- Worktree status at this implementation checkpoint: SearchAPI message results now hydrate Chatwoot `_message` fields with conversation display IDs, sender push data, and attachment push data from DB-backed handlers plus Meilisearch document payloads, while retaining P3.85 conversation payloads, 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.86 search message payload parity | Implemented for reused SearchAPI message cards: DB-backed handlers now serialize message search hits through the Chatwoot `_message` contract with conversation display IDs, sender push data, and attachment push data; Meilisearch message documents and reindex/durable indexing paths now carry display-ID and attachment payload data when relations are loaded. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for exact AgentBot sender push data, voice-call `call` payloads, or provider-specific attachment metadata beyond the inspected frontend card usage. | Focused SearchHandler, SearchRepo, DurableSearchIndexer, reindex, and document-builder tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| 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. |
|
||||
@@ -249,6 +250,7 @@ These rows are the executable development plan from this point forward. A checkp
|
||||
|
||||
| ID | Owner files | Reference files | Work to land | Exit gate |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| P3.86 search message payload parity | `internal/handler/api/v1/search_handler.go`, `internal/search/engine.go`, `internal/service/search_indexer_worker.go`, `cmd/reindex_search/main.go`, search handler/document/indexer tests | `reference/chatwoot/app/views/api/v1/models/_message.json.jbuilder`, `reference/chatwoot/app/models/message.rb#push_event_data`, `reference/chatwoot/app/models/attachment.rb#push_event_data`, `reference/chatwoot/app/javascript/dashboard/modules/search/components/SearchResultMessagesList.vue`, `SearchResultMessageItem.vue` | Message SearchAPI results now match the reused frontend card needs from Chatwoot's `_message` serializer: `conversation_id` is the conversation display ID for navigation, `sender` is hydrated from persisted contact/user rows for DB-backed handlers, attachments use Chatwoot-style push data, and Meilisearch message documents carry display-ID/attachment payload data when relations are preloaded by durable indexing and full reindex. | Review by `feat(search): align message result payloads`; focused SearchHandler, SearchRepo, DurableSearchIndexer, reindex, and document-builder tests prove DB-backed payload hydration plus Meilisearch nested payload shape; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| P3.83 search conversation ordering parity | `internal/repository/search_repo.go`, `internal/repository/search_repo_test.go` | `reference/chatwoot/app/services/search_service.rb#filter_conversations` | Conversation DB fallback search default ordering now uses `conversations.created_at DESC, conversations.id DESC`, matching Chatwoot `@conversations = conversations_query.order('conversations.created_at DESC')`, instead of the older local `last_activity_at DESC` conversation-list default. | Review by `feat(search): align conversation search order`; focused test proves created-at ordering wins even when `last_activity_at` points the other way; 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. |
|
||||
| P3.82 search contact-conversation reindex parity | `internal/service/contact_service.go`, `internal/service/search_indexer_hooks_test.go` | `reference/chatwoot/app/services/search_service.rb#filter_conversations`, P3.81 Meilisearch conversation document contract | Contact search indexing now fans out to related account-scoped conversations after the contact document is indexed. Each related conversation is loaded with `Contact` preloaded and passed to `IndexConversation`, keeping Meilisearch conversation identity text aligned with contact direct updates and import merge updates. | Review by `feat(search): reindex contact conversations`; focused test proves contact update indexes both the contact and its conversation with the updated contact name; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
| P3.81 search conversation identity parity | `internal/repository/search_repo.go`, `internal/search/engine.go`, `internal/model/conversation.go`, `internal/service/search_indexer_worker.go`, `cmd/reindex_search/main.go`, repository/search/service tests | `reference/chatwoot/app/services/search_service.rb#filter_conversations`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | Conversation global/entity search now matches Chatwoot's identity search contract: DB fallback matches `CAST(conversations.display_id AS TEXT)` plus joined contact `name`, `email`, `phone_number`, and `identifier`, while label and message-content text are not conversation-search matches. Meilisearch conversation documents use display/contact identity text in `title`/`content`; the conversation model exposes a `Contact` relation for preload; durable search indexing and full reindex preload contacts before indexing conversation documents. | Review by `feat(search): align conversation identity search`; focused tests cover DB display/contact matching, DB label/message exclusion, Meilisearch document content, and durable contact preload; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
|
||||
@@ -358,6 +360,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(search): align message result payloads` | Advances P3.86 with Chatwoot SearchAPI message result payload parity. Message search hits now route to conversation display IDs instead of DB IDs, expose `sender` push data for DB-backed results, expose attachment push data for DB-backed and Meilisearch document results, and index/reindex message documents with preloaded conversation/attachment display data where available. | `go test ./internal/handler/api/v1 ./internal/search ./internal/repository ./internal/service ./cmd/reindex_search -run 'TestSearchHandler_SearchMessages\|TestDocumentBuildersSetStableUIDAndType\|TestMessageDocumentCarriesChatwootPayloadData\|TestSearchRepo_SearchMessages\|TestDurableSearchIndexer' -count=1` passed with workspace-local temp/cache dirs; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.86 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(search): align conversation result payloads` | Advances P3.85 with Chatwoot search conversation-result payload parity. SearchAPI conversation hits now expose nested `message`, `contact`, `inbox`, `agent`, and `additional_attributes` data from both DB fallback rows and Meilisearch documents, and the indexing/reindexing paths preload the display relations needed by the reused frontend cards. | 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. | Move P3.85 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
| `feat(search): align agent sender filters` | Advances P3.84 with Chatwoot SearchAPI sender filter parity. The reused frontend `from=agent:id` filter now matches locally persisted agent/user sender variants in DB fallback and Meilisearch filters, while contact and bot sender filters tolerate Rails/local casing drift. | 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. | Move P3.84 to Review; continue search payload smoke or the next Phase 2/3 drift slice. |
|
||||
| `feat(search): align conversation search order` | Advances P3.83 with Chatwoot `SearchService#filter_conversations` ordering parity. DB fallback conversation search now defaults to `conversations.created_at DESC` with deterministic `id DESC`, instead of reusing the normal conversation list `last_activity_at` ordering. | `go test ./internal/repository -run 'TestSearchRepo_SearchConversations_DefaultOrderUsesCreatedAt\|TestSearchRepo_SearchConversations_(DisplayAndContactMatch\|DoesNotMatchLabelsOrMessages)' -count=1`; 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. | Move P3.83 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
|
||||
@@ -608,9 +611,9 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
## Next Slice Contract
|
||||
|
||||
Committed implementation baseline: `0b47066 feat(search): align conversation result payloads` closes P3.85 SearchAPI conversation payload parity for the inspected `reference/chatwoot` search controller, Jbuilder views, and reused dashboard search result cards.
|
||||
Committed implementation baseline: the current checkpoint is prepared as `feat(search): align message result payloads`, closing P3.86 SearchAPI message payload parity for the inspected `reference/chatwoot` message serializer, push-event data, and reused dashboard message search result cards.
|
||||
|
||||
Completed documentation slice: this checkpoint keeps the plan self-contained for follow-up work. The user-confirmed scope, Hermes source-plan landing map, active route/search/enterprise contracts, current route-parity numbers, next executable queue, P3.84/P3.85 commit ledger rows, and progress log are all represented in this document.
|
||||
Completed documentation slice: this checkpoint keeps the plan self-contained for follow-up work. The user-confirmed scope, Hermes source-plan landing map, active route/search/enterprise contracts, current route-parity numbers, next executable queue, P3.86 commit ledger row, and progress log are all represented in this document.
|
||||
|
||||
Next implementation slice: do not start from older Hermes TODOs directly. Continue with one of these evidence-backed paths: Phase 2/3 route/controller/serializer drift audit, Phase 6 placeholder audit, or B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Every discovered failure becomes a named row in the Active Remaining Work Board before implementation.
|
||||
|
||||
@@ -823,7 +826,7 @@ B6 request and payload matrix after `a16c23c`:
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /api/v1/accounts/:account_id/search` | `q`, `page`, default 15 items, existing local filters, plus `since`, `until`, `from`, `inbox_id`. | `{ payload: { conversations, contacts, messages, articles } }`. | `TestSearchHandler_GlobalSearch_Success`, `TestParseSearchFilter_ChatwootSearchParams`. |
|
||||
| `GET /api/v1/accounts/:account_id/search/conversations` | `q`, `page`, status/priority/assignee/team/inbox/labels/date filters. | `{ payload: { conversations: [...] } }`. | `TestSearchHandler_SearchConversations_Success`. |
|
||||
| `GET /api/v1/accounts/:account_id/search/messages` | `q`, `page`, `message_type`, `sender_type`, `from`, `sender_id`, `content_type`, `private`, `inbox_id`, `since`, `until`. | `{ payload: { messages: [...] } }` with numeric Chatwoot `message_type` for model and Meilisearch hit data. | `TestSearchHandler_SearchMessages_Success`, `TestSearchHandler_SearchMessages_MeiliHitPayloadShape`, `TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter`. |
|
||||
| `GET /api/v1/accounts/:account_id/search/messages` | `q`, `page`, `message_type`, `sender_type`, `from`, `sender_id`, `content_type`, `private`, `inbox_id`, `since`, `until`. | `{ payload: { messages: [...] } }` with numeric Chatwoot `message_type`, conversation display IDs, sender push data, and attachment push data for DB-backed and Meilisearch hit data. | `TestSearchHandler_SearchMessages_Success`, `TestSearchHandler_SearchMessages_MeiliHitPayloadShape`, `TestSearchHandler_SearchMessages_HydratesChatwootMessagePayload`, `TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter`. |
|
||||
| `GET /api/v1/accounts/:account_id/search/contacts` | `q`, `page`, `contact_source`. | `{ payload: { contacts: [...] } }`. | `TestSearchHandler_SearchContacts_Success`. |
|
||||
| `GET /api/v1/accounts/:account_id/search/articles` | `q`, `page`, `portal_id`, `article_status`, `locale`. | `{ payload: { articles: [...] } }`. | `TestSearchHandler_SearchArticles_Success`. |
|
||||
|
||||
@@ -2720,3 +2723,4 @@ Verification milestone gates:
|
||||
- 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.
|
||||
- 2026-06-07: Documentation checkpoint prepared as `docs: reconcile parity tracker`; current baseline now points at `0b47066 feat(search): align conversation result payloads`, the stale P3.39 next-slice wording is replaced, and the Commit Ledger now includes P3.84/P3.85 so the plan can be followed from this document without reading older Hermes notes. Verification for this docs-only checkpoint: `git diff --check`; no Go code or route artifacts changed.
|
||||
- 2026-06-07: P3.86 search message payload checkpoint prepared as `feat(search): align message result payloads`; audited Chatwoot `_message.json.jbuilder`, `Message#push_event_data`, `Attachment#push_event_data`, and reused `SearchResultMessagesList.vue`/`SearchResultMessageItem.vue`. GoChat SearchAPI message results now hydrate DB-backed hits with conversation display IDs, sender push data, and attachment push data; Meilisearch hit serialization preserves nested sender/attachment payloads; message documents, durable index replay, and full `cmd/reindex_search` now preload or carry display/attachment data where available. Focused SearchHandler, SearchRepo, DurableSearchIndexer, reindex, and document-builder tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
|
||||
|
||||
@@ -804,7 +804,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
CustomRole: v1.NewCustomRoleHandler(customRoleService).WithAuditService(auditService),
|
||||
AgentCapacity: v1.NewAgentCapacityHandler(agentCapacityPolicyService).WithAuditService(auditService),
|
||||
CsatMetrics: v1.NewCsatMetricsHandler(csatMetricsService),
|
||||
Search: v1.NewSearchHandler(searchService),
|
||||
Search: v1.NewSearchHandler(searchService, db),
|
||||
Widget: widgetHandler,
|
||||
// M13: SSO/SAML enterprise authentication handlers
|
||||
AccountSamlSettings: v1.NewAccountSamlSettingsHandler(accountSamlSettingsRepo),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
@@ -17,11 +19,16 @@ const chatwootSearchPerPage = 15
|
||||
// Reference: Chatwoot GlobalSearchService — cross-entity search with advanced filtering.
|
||||
type SearchHandler struct {
|
||||
svc *search.SearchService
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSearchHandler creates a new SearchHandler.
|
||||
func NewSearchHandler(svc *search.SearchService) *SearchHandler {
|
||||
return &SearchHandler{svc: svc}
|
||||
func NewSearchHandler(svc *search.SearchService, dbs ...*gorm.DB) *SearchHandler {
|
||||
h := &SearchHandler{svc: svc}
|
||||
if len(dbs) > 0 {
|
||||
h.db = dbs[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// GlobalSearch performs a unified search across conversations, messages, and contacts.
|
||||
@@ -79,7 +86,7 @@ func (h *SearchHandler) GlobalSearch(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeSearchPayload(result.Results)})
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeSearchPayload(c.Request.Context(), h.db, result.Results)})
|
||||
}
|
||||
|
||||
// SearchConversations performs a conversation-only search with advanced filters.
|
||||
@@ -131,7 +138,7 @@ func (h *SearchHandler) SearchConversations(c *gin.Context) {
|
||||
}
|
||||
|
||||
_ = total
|
||||
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversations": serializeSearchConversations(results)}})
|
||||
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"conversations": serializeSearchConversations(c.Request.Context(), h.db, results)}})
|
||||
}
|
||||
|
||||
// SearchMessages performs a message-only search with advanced filters.
|
||||
@@ -182,7 +189,7 @@ func (h *SearchHandler) SearchMessages(c *gin.Context) {
|
||||
}
|
||||
|
||||
_ = total
|
||||
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"messages": serializeSearchMessages(results)}})
|
||||
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"messages": serializeSearchMessages(c.Request.Context(), h.db, results)}})
|
||||
}
|
||||
|
||||
// SearchContacts performs a contact-only search with advanced filters.
|
||||
@@ -278,11 +285,11 @@ func (h *SearchHandler) SearchArticles(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"payload": gin.H{"articles": serializeSearchArticles(results)}})
|
||||
}
|
||||
|
||||
func serializeSearchPayload(results []search.SearchResult) gin.H {
|
||||
func serializeSearchPayload(ctx context.Context, db *gorm.DB, results []search.SearchResult) gin.H {
|
||||
return gin.H{
|
||||
"conversations": serializeSearchConversations(filterSearchResults(results, search.ResultTypeConversation)),
|
||||
"conversations": serializeSearchConversations(ctx, db, filterSearchResults(results, search.ResultTypeConversation)),
|
||||
"contacts": serializeSearchContacts(filterSearchResults(results, search.ResultTypeContact)),
|
||||
"messages": serializeSearchMessages(filterSearchResults(results, search.ResultTypeMessage)),
|
||||
"messages": serializeSearchMessages(ctx, db, filterSearchResults(results, search.ResultTypeMessage)),
|
||||
"articles": serializeSearchArticles(filterSearchResults(results, search.ResultTypeArticle)),
|
||||
}
|
||||
}
|
||||
@@ -308,20 +315,20 @@ func filterSearchResults(results []search.SearchResult, resultType search.Search
|
||||
return filtered
|
||||
}
|
||||
|
||||
func serializeSearchConversations(results []search.SearchResult) []map[string]any {
|
||||
func serializeSearchConversations(ctx context.Context, db *gorm.DB, results []search.SearchResult) []map[string]any {
|
||||
payload := make([]map[string]any, 0, len(results))
|
||||
for _, result := range results {
|
||||
payload = append(payload, serializeSearchConversation(result))
|
||||
payload = append(payload, serializeSearchConversation(ctx, db, result))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeSearchConversation(result search.SearchResult) map[string]any {
|
||||
func serializeSearchConversation(ctx context.Context, db *gorm.DB, result search.SearchResult) map[string]any {
|
||||
if conv, ok := result.Data.(model.Conversation); ok {
|
||||
return serializeSearchConversationModel(&conv)
|
||||
return serializeSearchConversationModel(ctx, db, &conv)
|
||||
}
|
||||
if conv, ok := result.Data.(*model.Conversation); ok && conv != nil {
|
||||
return serializeSearchConversationModel(conv)
|
||||
return serializeSearchConversationModel(ctx, db, conv)
|
||||
}
|
||||
root := searchDataRoot(result)
|
||||
data := nestedSearchDataFromRoot(root, "conversation")
|
||||
@@ -330,31 +337,31 @@ func serializeSearchConversation(result search.SearchResult) map[string]any {
|
||||
"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")),
|
||||
"message": serializeSearchConversationMessageMap(ctx, db, 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 {
|
||||
func serializeSearchConversationModel(ctx context.Context, db *gorm.DB, 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),
|
||||
"message": serializeSearchConversationMessageModel(ctx, db, conv),
|
||||
"contact": serializeSearchConversationContactModel(conv),
|
||||
"inbox": serializeSearchConversationInboxModel(conv),
|
||||
"agent": serializeSearchConversationAgentModel(conv),
|
||||
}
|
||||
}
|
||||
|
||||
func serializeSearchConversationMessageModel(conv *model.Conversation) map[string]any {
|
||||
func serializeSearchConversationMessageModel(ctx context.Context, db *gorm.DB, conv *model.Conversation) map[string]any {
|
||||
if len(conv.Messages) == 0 {
|
||||
return map[string]any{}
|
||||
}
|
||||
return serializeSearchMessageModel(&conv.Messages[0])
|
||||
return serializeSearchMessageModel(ctx, db, &conv.Messages[0])
|
||||
}
|
||||
|
||||
func serializeSearchConversationContactModel(conv *model.Conversation) map[string]any {
|
||||
@@ -395,11 +402,11 @@ func serializeSearchConversationAgentModel(conv *model.Conversation) map[string]
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func serializeSearchConversationMessageMap(data map[string]any) map[string]any {
|
||||
func serializeSearchConversationMessageMap(ctx context.Context, db *gorm.DB, 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}})
|
||||
return serializeSearchMessage(ctx, db, search.SearchResult{Data: map[string]any{"message": data}})
|
||||
}
|
||||
|
||||
func serializeSearchConversationContactMap(data map[string]any) map[string]any {
|
||||
@@ -481,37 +488,44 @@ func serializeSearchContactModel(contact *model.Contact) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func serializeSearchMessages(results []search.SearchResult) []map[string]any {
|
||||
func serializeSearchMessages(ctx context.Context, db *gorm.DB, results []search.SearchResult) []map[string]any {
|
||||
payload := make([]map[string]any, 0, len(results))
|
||||
for _, result := range results {
|
||||
payload = append(payload, serializeSearchMessage(result))
|
||||
payload = append(payload, serializeSearchMessage(ctx, db, result))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func serializeSearchMessage(result search.SearchResult) map[string]any {
|
||||
func serializeSearchMessage(ctx context.Context, db *gorm.DB, result search.SearchResult) map[string]any {
|
||||
if message, ok := result.Data.(model.Message); ok {
|
||||
return serializeSearchMessageModel(&message)
|
||||
return serializeSearchMessageModel(ctx, db, &message)
|
||||
}
|
||||
if message, ok := result.Data.(*model.Message); ok && message != nil {
|
||||
return serializeSearchMessageModel(message)
|
||||
return serializeSearchMessageModel(ctx, db, message)
|
||||
}
|
||||
data := nestedSearchData(result, "message")
|
||||
return map[string]any{
|
||||
"id": firstMapValue(data, "id"),
|
||||
"content": firstMapValue(data, "content"),
|
||||
"account_id": firstMapValue(data, "account_id"),
|
||||
"inbox_id": firstMapValue(data, "inbox_id"),
|
||||
"conversation_id": firstMapValue(data, "conversation_id"),
|
||||
"message_type": normalizeSearchMessageType(firstMapValue(data, "message_type")),
|
||||
"content_type": firstMapValue(data, "content_type"),
|
||||
"status": firstMapValue(data, "status"),
|
||||
"content_attributes": firstMapValue(data, "content_attributes"),
|
||||
"additional_attributes": firstMapValue(data, "additional_attributes"),
|
||||
"created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")),
|
||||
"private": firstMapValue(data, "private"),
|
||||
"source_id": firstMapValue(data, "source_id"),
|
||||
if db != nil {
|
||||
if payload, ok := loadSearchMessagePayload(ctx, db, uintFromAny(firstMapValue(data, "id")), result.AccountID); ok {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
return omitNilSearchMessageFields(map[string]any{
|
||||
"id": firstMapValue(data, "id"),
|
||||
"content": firstMapValue(data, "content"),
|
||||
"account_id": firstMapValue(data, "account_id"),
|
||||
"inbox_id": firstMapValue(data, "inbox_id"),
|
||||
"conversation_id": firstMapValue(data, "conversation_id"),
|
||||
"message_type": normalizeSearchMessageType(firstMapValue(data, "message_type")),
|
||||
"content_type": firstMapValue(data, "content_type"),
|
||||
"status": firstMapValue(data, "status"),
|
||||
"content_attributes": firstMapValue(data, "content_attributes"),
|
||||
"created_at": unixFromMapValue(firstMapValue(data, "created_at", "created_at_ts")),
|
||||
"private": firstMapValue(data, "private"),
|
||||
"source_id": firstMapValue(data, "source_id"),
|
||||
"echo_id": firstMapValue(data, "echo_id"),
|
||||
"sender": firstMapValue(data, "sender"),
|
||||
"attachments": firstMapValue(data, "attachments"),
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeSearchMessageType(value any) any {
|
||||
@@ -525,22 +539,83 @@ func normalizeSearchMessageType(value any) any {
|
||||
return messageTypeValue(s)
|
||||
}
|
||||
|
||||
func serializeSearchMessageModel(message *model.Message) map[string]any {
|
||||
return map[string]any{
|
||||
"id": message.ID,
|
||||
"content": message.Content,
|
||||
"account_id": message.AccountID,
|
||||
"inbox_id": message.InboxID,
|
||||
"conversation_id": message.ConversationID,
|
||||
"message_type": messageTypeValue(message.MessageType),
|
||||
"content_type": nonEmpty(message.ContentType, "text"),
|
||||
"status": nonEmpty(message.Status, "sent"),
|
||||
"content_attributes": jsonObject(message.ContentAttributes),
|
||||
"additional_attributes": jsonObject(message.AdditionalAttributes),
|
||||
"created_at": message.CreatedAt.Unix(),
|
||||
"private": message.Private,
|
||||
"source_id": message.SourceID,
|
||||
func serializeSearchMessageModel(ctx context.Context, db *gorm.DB, message *model.Message) map[string]any {
|
||||
if db != nil {
|
||||
if payload, ok := loadSearchMessagePayload(ctx, db, message.ID, message.AccountID); ok {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
return omitNilSearchMessageFields(map[string]any{
|
||||
"id": message.ID,
|
||||
"content": message.Content,
|
||||
"account_id": message.AccountID,
|
||||
"inbox_id": message.InboxID,
|
||||
"conversation_id": message.ConversationID,
|
||||
"message_type": messageTypeValue(message.MessageType),
|
||||
"content_type": nonEmpty(message.ContentType, "text"),
|
||||
"status": nonEmpty(message.Status, "sent"),
|
||||
"content_attributes": jsonObject(message.ContentAttributes),
|
||||
"created_at": message.CreatedAt.Unix(),
|
||||
"private": message.Private,
|
||||
"source_id": message.SourceID,
|
||||
"echo_id": message.EchoID,
|
||||
})
|
||||
}
|
||||
|
||||
func loadSearchMessagePayload(ctx context.Context, db *gorm.DB, messageID uint, accountID uint) (map[string]any, bool) {
|
||||
if db == nil || messageID == 0 {
|
||||
return nil, false
|
||||
}
|
||||
var message model.Message
|
||||
q := db.WithContext(ctx).Where("id = ?", messageID)
|
||||
if accountID != 0 {
|
||||
q = q.Where("account_id = ?", accountID)
|
||||
}
|
||||
if err := q.First(&message).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var conversation model.Conversation
|
||||
if err := db.WithContext(ctx).
|
||||
Where("id = ? AND account_id = ?", message.ConversationID, message.AccountID).
|
||||
First(&conversation).Error; err != nil {
|
||||
return chatwootMessagePayloadMap(serializeMessage(ctx, db, &message, nil)), true
|
||||
}
|
||||
return chatwootMessagePayloadMap(serializeMessage(ctx, db, &message, &conversation)), true
|
||||
}
|
||||
|
||||
func chatwootMessagePayloadMap(payload chatwootMessagePayload) map[string]any {
|
||||
data := map[string]any{
|
||||
"id": payload.ID,
|
||||
"content": payload.Content,
|
||||
"inbox_id": payload.InboxID,
|
||||
"conversation_id": payload.ConversationID,
|
||||
"message_type": payload.MessageType,
|
||||
"content_type": payload.ContentType,
|
||||
"status": payload.Status,
|
||||
"content_attributes": payload.ContentAttributes,
|
||||
"created_at": payload.CreatedAt,
|
||||
"private": payload.Private,
|
||||
"source_id": payload.SourceID,
|
||||
}
|
||||
if payload.EchoID != "" {
|
||||
data["echo_id"] = payload.EchoID
|
||||
}
|
||||
if len(payload.Sender) > 0 {
|
||||
data["sender"] = payload.Sender
|
||||
}
|
||||
if len(payload.Attachments) > 0 {
|
||||
data["attachments"] = payload.Attachments
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func omitNilSearchMessageFields(data map[string]any) map[string]any {
|
||||
for _, key := range []string{"echo_id", "sender", "attachments"} {
|
||||
if data[key] == nil || data[key] == "" {
|
||||
delete(data, key)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func serializeSearchArticles(results []search.SearchResult) []map[string]any {
|
||||
@@ -670,3 +745,23 @@ func unixFromMapValue(value any) any {
|
||||
return typed
|
||||
}
|
||||
}
|
||||
|
||||
func uintFromAny(value any) uint {
|
||||
switch typed := value.(type) {
|
||||
case uint:
|
||||
return typed
|
||||
case int:
|
||||
if typed > 0 {
|
||||
return uint(typed)
|
||||
}
|
||||
case int64:
|
||||
if typed > 0 {
|
||||
return uint(typed)
|
||||
}
|
||||
case float64:
|
||||
if typed > 0 {
|
||||
return uint(typed)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/search"
|
||||
@@ -342,9 +345,11 @@ func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) {
|
||||
"id": float64(9),
|
||||
"content": "hello from meili",
|
||||
"account_id": float64(1),
|
||||
"conversation_id": float64(3),
|
||||
"conversation_id": float64(42),
|
||||
"message_type": "incoming",
|
||||
"created_at_ts": float64(1700000000),
|
||||
"sender": map[string]any{"id": float64(7), "name": "Ada Contact"},
|
||||
"attachments": []any{map[string]any{"id": float64(11), "file_type": "file", "data_url": "https://files.example/doc.pdf"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -368,6 +373,68 @@ func TestSearchHandler_SearchMessages_MeiliHitPayloadShape(t *testing.T) {
|
||||
message := messages[0].(map[string]any)
|
||||
assert.Equal(t, float64(0), message["message_type"])
|
||||
assert.Equal(t, float64(1700000000), message["created_at"])
|
||||
assert.Equal(t, float64(42), message["conversation_id"])
|
||||
assert.Equal(t, "Ada Contact", message["sender"].(map[string]any)["name"])
|
||||
attachments := message["attachments"].([]any)
|
||||
require.Len(t, attachments, 1)
|
||||
assert.Equal(t, "https://files.example/doc.pdf", attachments[0].(map[string]any)["data_url"])
|
||||
}
|
||||
|
||||
func TestSearchHandler_SearchMessages_HydratesChatwootMessagePayload(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:search-message-payload?mode=memory&cache=private"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}))
|
||||
|
||||
account := model.Account{Name: "Acme"}
|
||||
require.NoError(t, db.Create(&account).Error)
|
||||
inbox := model.Inbox{AccountID: account.ID, Name: "Website", ChannelType: "web_widget"}
|
||||
require.NoError(t, db.Create(&inbox).Error)
|
||||
contact := model.Contact{AccountID: account.ID, Name: "Ada Contact", Email: "ada@example.com"}
|
||||
require.NoError(t, db.Create(&contact).Error)
|
||||
agent := model.User{Name: "Agent One", Email: "agent@example.com"}
|
||||
require.NoError(t, db.Create(&agent).Error)
|
||||
displayID := uint(42)
|
||||
conversation := model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open"}
|
||||
require.NoError(t, db.Create(&conversation).Error)
|
||||
message := model.Message{
|
||||
AccountID: account.ID,
|
||||
InboxID: inbox.ID,
|
||||
ConversationID: conversation.ID,
|
||||
SenderID: &agent.ID,
|
||||
SenderType: "user",
|
||||
MessageType: "outgoing",
|
||||
ContentType: "text",
|
||||
Status: "sent",
|
||||
Content: "hello with attachment",
|
||||
ContentAttributes: datatypes.JSON([]byte(`{"source":"search"}`)),
|
||||
}
|
||||
require.NoError(t, db.Create(&message).Error)
|
||||
require.NoError(t, db.Create(&model.Attachment{AccountID: account.ID, MessageID: message.ID, FileType: "file", FileName: "quote.pdf", FileURL: "https://files.example/quote.pdf", FileSize: 128}).Error)
|
||||
|
||||
repo := &mockSearchRepo{messages: []model.Message{{Base: model.Base{ID: message.ID}, AccountID: account.ID}}, msgTotal: 1}
|
||||
svc := search.NewSearchService(repo)
|
||||
handler := NewSearchHandler(svc, db)
|
||||
router := setupSearchHandlerRouter(handler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/messages?q=hello", 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)
|
||||
messages := payload["messages"].([]any)
|
||||
require.Len(t, messages, 1)
|
||||
got := messages[0].(map[string]any)
|
||||
assert.Equal(t, float64(displayID), got["conversation_id"])
|
||||
sender := got["sender"].(map[string]any)
|
||||
assert.Equal(t, "Agent One", sender["name"])
|
||||
attachments := got["attachments"].([]any)
|
||||
require.Len(t, attachments, 1)
|
||||
attachment := attachments[0].(map[string]any)
|
||||
assert.Equal(t, "file", attachment["file_type"])
|
||||
assert.Equal(t, "https://files.example/quote.pdf", attachment["data_url"])
|
||||
}
|
||||
|
||||
func TestSearchHandler_SearchMessages_InvalidAccountID(t *testing.T) {
|
||||
|
||||
@@ -23,6 +23,9 @@ type Message struct {
|
||||
ContentAttributes datatypes.JSON `gorm:"type:jsonb" json:"content_attributes,omitempty"` // attachments, mentions, etc
|
||||
AdditionalAttributes datatypes.JSON `gorm:"type:jsonb" json:"additional_attributes,omitempty"`
|
||||
ExternalSourceIDs datatypes.JSON `gorm:"type:jsonb" json:"external_source_ids,omitempty"` // external platform IDs
|
||||
|
||||
Conversation *Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
|
||||
Attachments []Attachment `gorm:"foreignKey:MessageID" json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
func (Message) TableName() string { return "messages" }
|
||||
|
||||
@@ -2,7 +2,9 @@ package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -247,6 +249,7 @@ func conversationSearchContent(conv model.Conversation) string {
|
||||
func MessageDocument(msg model.Message) SearchDocument {
|
||||
inboxID := msg.InboxID
|
||||
conversationID := msg.ConversationID
|
||||
messageData := messageDocumentData(msg)
|
||||
doc := SearchDocument{
|
||||
ID: msg.ID,
|
||||
Type: ResultTypeMessage,
|
||||
@@ -264,12 +267,99 @@ func MessageDocument(msg model.Message) SearchDocument {
|
||||
ConversationID: &conversationID,
|
||||
CreatedAtTS: timestamp(msg.CreatedAt),
|
||||
UpdatedAtTS: timestamp(msg.UpdatedAt),
|
||||
Data: map[string]interface{}{"message": msg},
|
||||
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{
|
||||
|
||||
@@ -69,6 +69,39 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) {
|
||||
assert.Equal(t, uint(15), doc.Data["message"].(map[string]interface{})["id"])
|
||||
}
|
||||
|
||||
func TestMessageDocumentCarriesChatwootPayloadData(t *testing.T) {
|
||||
displayID := uint(42)
|
||||
msg := model.Message{
|
||||
Base: model.Base{ID: 15, CreatedAt: time.Unix(1772884700, 0)},
|
||||
AccountID: 3,
|
||||
InboxID: 7,
|
||||
ConversationID: 12,
|
||||
Content: "hello with file",
|
||||
MessageType: "incoming",
|
||||
ContentType: "text",
|
||||
Status: "sent",
|
||||
Conversation: &model.Conversation{Base: model.Base{ID: 12}, DisplayID: &displayID},
|
||||
Attachments: []model.Attachment{{
|
||||
Base: model.Base{ID: 99},
|
||||
AccountID: 3,
|
||||
MessageID: 15,
|
||||
FileType: "audio",
|
||||
FileName: "note.mp3",
|
||||
FileURL: "https://files.example/note.mp3",
|
||||
Metadata: `{"transcribed_text":"hello transcript"}`,
|
||||
}},
|
||||
}
|
||||
|
||||
doc := MessageDocument(msg)
|
||||
|
||||
message := doc.Data["message"].(map[string]interface{})
|
||||
assert.Equal(t, displayID, message["conversation_id"])
|
||||
attachments := message["attachments"].([]map[string]interface{})
|
||||
require.Len(t, attachments, 1)
|
||||
assert.Equal(t, "audio", attachments[0]["file_type"])
|
||||
assert.Equal(t, "hello transcript", attachments[0]["transcribed_text"])
|
||||
}
|
||||
|
||||
func TestContactDocumentSetsResolvedScopeFields(t *testing.T) {
|
||||
doc := ContactDocument(model.Contact{
|
||||
Base: model.Base{ID: 5},
|
||||
|
||||
@@ -61,6 +61,9 @@ func (i *DurableSearchIndexer) IndexMessage(ctx context.Context, message *model.
|
||||
return nil
|
||||
}
|
||||
return i.enqueueOrIndex(ctx, "message", message.AccountID, message.ID, func() error {
|
||||
if enriched, err := i.loadMessage(ctx, message.AccountID, message.ID); err == nil && enriched != nil {
|
||||
return i.delegate.IndexMessage(ctx, enriched)
|
||||
}
|
||||
return i.delegate.IndexMessage(ctx, message)
|
||||
})
|
||||
}
|
||||
@@ -160,11 +163,14 @@ func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchI
|
||||
}
|
||||
return i.delegate.IndexConversation(ctx, item)
|
||||
case "message":
|
||||
var item model.Message
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
item, err := i.loadMessage(ctx, payload.AccountID, payload.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return i.delegate.IndexMessage(ctx, &item)
|
||||
if item == nil {
|
||||
return nil
|
||||
}
|
||||
return i.delegate.IndexMessage(ctx, item)
|
||||
case "contact":
|
||||
var item model.Contact
|
||||
if err := i.load(ctx, payload, &item); err != nil {
|
||||
@@ -188,6 +194,25 @@ func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchI
|
||||
}
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) loadMessage(ctx context.Context, accountID, id uint) (*model.Message, error) {
|
||||
if i.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var item model.Message
|
||||
err := i.db.WithContext(ctx).
|
||||
Preload("Conversation").
|
||||
Preload("Attachments", func(db *gorm.DB) *gorm.DB { return db.Order("id ASC") }).
|
||||
Where("id = ? AND account_id = ?", id, accountID).
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, i.performDelete(ctx, searchIndexJob{Operation: "delete", Entity: "message", AccountID: accountID, ID: id})
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (i *DurableSearchIndexer) loadConversation(ctx context.Context, accountID, id uint) (*model.Conversation, error) {
|
||||
var item model.Conversation
|
||||
err := i.db.WithContext(ctx).
|
||||
|
||||
Reference in New Issue
Block a user