From 9027551768d7d08a1e0b1897fc19a9fc866c8305 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 09:28:00 +0800 Subject: [PATCH] feat(search): align article result payloads --- cmd/reindex_search/main.go | 5 ++- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 18 ++++---- internal/handler/api/v1/search_handler.go | 41 +++++++++++++++---- .../handler/api/v1/search_handler_test.go | 34 +++++++++++++++ internal/search/engine.go | 26 +++++++++++- internal/search/engine_test.go | 24 +++++++++++ internal/service/search_indexer_worker.go | 31 ++++++++++++-- .../service/search_indexer_worker_test.go | 35 ++++++++++++++++ 8 files changed, 194 insertions(+), 20 deletions(-) diff --git a/cmd/reindex_search/main.go b/cmd/reindex_search/main.go index a4f146e8..b8ff585e 100644 --- a/cmd/reindex_search/main.go +++ b/cmd/reindex_search/main.go @@ -203,7 +203,10 @@ func reindexContacts(ctx context.Context, engine search.SearchEngine, db *gorm.D var lastID uint for { var rows []model.Contact - q := db.WithContext(ctx).Where("id > ?", lastID).Order("id ASC").Limit(batchSize) + q := db.WithContext(ctx). + Preload("Portal"). + Preload("Category"). + 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 331f8a08..dc8a93e0 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.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. +- Current tracking checkpoint: 2026-06-07 P3.87 search article payload parity, prepared as `feat(search): align article result payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align article result payloads`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot search article partial payload parity for `portal_slug` and `category_name`. 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 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. +- Worktree status at this implementation checkpoint: SearchAPI article results now hydrate Chatwoot `_article` fields `portal_slug` and `category_name` from DB-backed handlers and Meilisearch documents, while retaining P3.86 message payloads, 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.87 search article payload parity | Implemented for reused SearchAPI article cards: DB-backed handlers and Meilisearch documents now carry Chatwoot `_article` `portal_slug` and `category_name` fields, so article search cards can route to `portals/:portalSlug/:locale/articles/edit/:id` and display category names without adapter code. Durable index replay and full reindex preload article portal/category relations. | Keep in Review; reopen from B12 search/help-center smoke or fresh reference evidence for article description payload drift, translated-article locale routing, or category/portal deletion edge cases. | 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.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. | @@ -250,6 +251,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.87 search article 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/accounts/search/_article.json.jbuilder`, `articles.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/modules/search/components/SearchResultArticlesList.vue`, `SearchResultArticleItem.vue` | Article SearchAPI results now match the reused frontend card route/display fields: `portal_slug` is hydrated from the article portal, `category_name` is hydrated from the optional article category, DB-backed handlers reload those relations for result rows and Meilisearch hits, and durable/full reindex paths preload the relations before document creation. | Review by `feat(search): align article 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.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. | @@ -360,6 +362,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(search): align article result payloads` | Advances P3.87 with Chatwoot SearchAPI article result payload parity. Article search hits now include `portal_slug` and `category_name` from the same partial fields consumed by the reused article search card; DB-backed handlers reload portal/category relations for article results and Meilisearch hits, and durable/full reindex paths preload those relations before document creation. | `go test ./internal/handler/api/v1 ./internal/search ./internal/repository ./internal/service ./cmd/reindex_search -run 'TestSearchHandler_SearchArticles\|TestArticleDocumentCarriesSearchPartialData\|TestSearchRepo_SearchArticles\|TestDurableSearchIndexerPreloadsArticlePortalAndCategory' -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.87 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `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. | @@ -611,9 +614,9 @@ This ledger records the committed parity checkpoints that future slices should b ## Next Slice Contract -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. +Committed implementation baseline: the current checkpoint is prepared as `feat(search): align article result payloads`, closing P3.87 SearchAPI article payload parity for the inspected `reference/chatwoot` search article partial and reused dashboard article 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.86 commit ledger row, 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.87 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. @@ -828,7 +831,7 @@ B6 request and payload matrix after `a16c23c`: | `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`, 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`. | +| `GET /api/v1/accounts/:account_id/search/articles` | `q`, `page`, `portal_id`, `article_status`, `locale`. | `{ payload: { articles: [...] } }` with Chatwoot `_article` route/display fields including `portal_slug`, `category_name`, and `updated_at`. | `TestSearchHandler_SearchArticles_Success`, `TestSearchHandler_SearchArticles_HydratesPortalAndCategoryPayload`, `TestArticleDocumentCarriesSearchPartialData`. | B6 current checkpoint: @@ -2724,3 +2727,4 @@ Verification milestone gates: - 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. +- 2026-06-07: P3.87 search article payload checkpoint prepared as `feat(search): align article result payloads`; audited Chatwoot search `_article.json.jbuilder`, `articles.json.jbuilder`, and reused `SearchResultArticlesList.vue`/`SearchResultArticleItem.vue`. GoChat SearchAPI article results now hydrate `portal_slug` and `category_name` from portal/category relations for DB-backed handlers and Meilisearch hits; article documents, durable index replay, and full `cmd/reindex_search` now preload or carry those route/display fields 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. diff --git a/internal/handler/api/v1/search_handler.go b/internal/handler/api/v1/search_handler.go index 19333884..3aaba3a6 100644 --- a/internal/handler/api/v1/search_handler.go +++ b/internal/handler/api/v1/search_handler.go @@ -282,7 +282,7 @@ func (h *SearchHandler) SearchArticles(c *gin.Context) { } _ = total - c.JSON(http.StatusOK, gin.H{"payload": gin.H{"articles": serializeSearchArticles(results)}}) + c.JSON(http.StatusOK, gin.H{"payload": gin.H{"articles": serializeSearchArticles(c.Request.Context(), h.db, results)}}) } func serializeSearchPayload(ctx context.Context, db *gorm.DB, results []search.SearchResult) gin.H { @@ -290,7 +290,7 @@ func serializeSearchPayload(ctx context.Context, db *gorm.DB, results []search.S "conversations": serializeSearchConversations(ctx, db, filterSearchResults(results, search.ResultTypeConversation)), "contacts": serializeSearchContacts(filterSearchResults(results, search.ResultTypeContact)), "messages": serializeSearchMessages(ctx, db, filterSearchResults(results, search.ResultTypeMessage)), - "articles": serializeSearchArticles(filterSearchResults(results, search.ResultTypeArticle)), + "articles": serializeSearchArticles(ctx, db, filterSearchResults(results, search.ResultTypeArticle)), } } @@ -618,22 +618,27 @@ func omitNilSearchMessageFields(data map[string]any) map[string]any { return data } -func serializeSearchArticles(results []search.SearchResult) []map[string]any { +func serializeSearchArticles(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, serializeSearchArticle(result)) + payload = append(payload, serializeSearchArticle(ctx, db, result)) } return payload } -func serializeSearchArticle(result search.SearchResult) map[string]any { +func serializeSearchArticle(ctx context.Context, db *gorm.DB, result search.SearchResult) map[string]any { if article, ok := result.Data.(model.Article); ok { - return serializeSearchArticleModel(&article) + return serializeSearchArticleModel(ctx, db, &article) } if article, ok := result.Data.(*model.Article); ok && article != nil { - return serializeSearchArticleModel(article) + return serializeSearchArticleModel(ctx, db, article) } data := nestedSearchData(result, "article") + if db != nil { + if payload, ok := loadSearchArticlePayload(ctx, db, uintFromAny(firstMapValue(data, "id")), result.AccountID); ok { + return payload + } + } return map[string]any{ "id": firstMapValue(data, "id"), "title": firstMapValue(data, "title"), @@ -648,7 +653,12 @@ func serializeSearchArticle(result search.SearchResult) map[string]any { } } -func serializeSearchArticleModel(article *model.Article) map[string]any { +func serializeSearchArticleModel(ctx context.Context, db *gorm.DB, article *model.Article) map[string]any { + if db != nil { + if payload, ok := loadSearchArticlePayload(ctx, db, article.ID, article.AccountID); ok { + return payload + } + } portalSlug := "" if article.Portal.Slug != "" { portalSlug = article.Portal.Slug @@ -671,6 +681,21 @@ func serializeSearchArticleModel(article *model.Article) map[string]any { } } +func loadSearchArticlePayload(ctx context.Context, db *gorm.DB, articleID uint, accountID uint) (map[string]any, bool) { + if db == nil || articleID == 0 { + return nil, false + } + var article model.Article + q := db.WithContext(ctx).Preload("Portal").Preload("Category").Where("id = ?", articleID) + if accountID != 0 { + q = q.Where("account_id = ?", accountID) + } + if err := q.First(&article).Error; err != nil { + return nil, false + } + return serializeSearchArticleModel(ctx, nil, &article), true +} + func nestedSearchData(result search.SearchResult, key string) map[string]any { return nestedSearchDataFromRoot(searchDataRoot(result), key) } diff --git a/internal/handler/api/v1/search_handler_test.go b/internal/handler/api/v1/search_handler_test.go index 1e9dd6d2..5d8eb6b7 100644 --- a/internal/handler/api/v1/search_handler_test.go +++ b/internal/handler/api/v1/search_handler_test.go @@ -545,6 +545,40 @@ func TestSearchHandler_SearchArticles_Success(t *testing.T) { require.Len(t, results, 1) } +func TestSearchHandler_SearchArticles_HydratesPortalAndCategoryPayload(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:search-article-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.Portal{}, &model.Category{}, &model.Article{})) + + account := model.Account{Name: "Acme"} + require.NoError(t, db.Create(&account).Error) + portal := model.Portal{AccountID: account.ID, Name: "Help Center", Slug: "help-center", Locale: "en"} + require.NoError(t, db.Create(&portal).Error) + category := model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Billing", Slug: "billing", Locale: "en"} + require.NoError(t, db.Create(&category).Error) + article := model.Article{AccountID: account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing FAQ", Slug: "billing-faq", Content: "billing help", Status: "published", Locale: "en"} + require.NoError(t, db.Create(&article).Error) + + repo := &mockSearchRepo{articles: []model.Article{{Base: model.Base{ID: article.ID}, AccountID: account.ID}}, articleTotal: 1} + svc := search.NewSearchService(repo) + handler := NewSearchHandler(svc, db) + router := setupSearchHandlerRouter(handler) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api/v1/accounts/1/search/articles?q=billing", 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) + articles := payload["articles"].([]any) + require.Len(t, articles, 1) + got := articles[0].(map[string]any) + assert.Equal(t, "help-center", got["portal_slug"]) + assert.Equal(t, "Billing", got["category_name"]) +} + func TestSearchHandler_SearchArticles_InvalidAccountID(t *testing.T) { repo := &mockSearchRepo{} svc := search.NewSearchService(repo) diff --git a/internal/search/engine.go b/internal/search/engine.go index 473c3060..d3d24ab1 100644 --- a/internal/search/engine.go +++ b/internal/search/engine.go @@ -399,6 +399,7 @@ func CompanyDocument(company model.Company) SearchDocument { func ArticleDocument(article model.Article) SearchDocument { portalID := article.PortalID + articleData := articleDocumentData(article) doc := SearchDocument{ ID: article.ID, Type: ResultTypeArticle, @@ -411,12 +412,35 @@ func ArticleDocument(article model.Article) SearchDocument { Locale: article.Locale, CreatedAtTS: timestamp(article.CreatedAt), UpdatedAtTS: timestamp(article.UpdatedAt), - Data: map[string]interface{}{"article": article}, + Data: map[string]interface{}{"article": articleData}, } doc.ensureUID() return doc } +func articleDocumentData(article model.Article) map[string]interface{} { + portalSlug := "" + if article.Portal.Slug != "" { + portalSlug = article.Portal.Slug + } + categoryName := "" + if article.Category != nil && article.Category.Name != "" { + categoryName = article.Category.Name + } + return map[string]interface{}{ + "id": article.ID, + "title": article.Title, + "locale": article.Locale, + "content": article.Content, + "slug": article.Slug, + "portal_slug": portalSlug, + "account_id": article.AccountID, + "category_name": categoryName, + "status": article.Status, + "updated_at": timestamp(article.UpdatedAt), + } +} + func splitLabels(raw string) []string { if raw == "" { return nil diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go index f4a4e5a8..31f4a586 100644 --- a/internal/search/engine_test.go +++ b/internal/search/engine_test.go @@ -102,6 +102,30 @@ func TestMessageDocumentCarriesChatwootPayloadData(t *testing.T) { assert.Equal(t, "hello transcript", attachments[0]["transcribed_text"]) } +func TestArticleDocumentCarriesSearchPartialData(t *testing.T) { + categoryID := uint(9) + article := model.Article{ + Base: model.Base{ID: 21, UpdatedAt: time.Unix(1772884800, 0)}, + AccountID: 3, + PortalID: 5, + CategoryID: &categoryID, + Title: "Billing FAQ", + Slug: "billing-faq", + Locale: "en", + Content: "billing content", + Status: "published", + Portal: model.Portal{Base: model.Base{ID: 5}, Slug: "help-center"}, + Category: &model.Category{Base: model.Base{ID: categoryID}, Name: "Billing"}, + } + + doc := ArticleDocument(article) + + payload := doc.Data["article"].(map[string]interface{}) + assert.Equal(t, "help-center", payload["portal_slug"]) + assert.Equal(t, "Billing", payload["category_name"]) + assert.Equal(t, int64(1772884800), payload["updated_at"]) +} + func TestContactDocumentSetsResolvedScopeFields(t *testing.T) { doc := ContactDocument(model.Contact{ Base: model.Base{ID: 5}, diff --git a/internal/service/search_indexer_worker.go b/internal/service/search_indexer_worker.go index e8222089..8144df69 100644 --- a/internal/service/search_indexer_worker.go +++ b/internal/service/search_indexer_worker.go @@ -103,6 +103,9 @@ func (i *DurableSearchIndexer) IndexArticle(ctx context.Context, article *model. return nil } return i.enqueueOrIndex(ctx, "article", article.AccountID, article.ID, func() error { + if enriched, err := i.loadArticle(ctx, article.AccountID, article.ID); err == nil && enriched != nil { + return i.delegate.IndexArticle(ctx, enriched) + } return i.delegate.IndexArticle(ctx, article) }) } @@ -184,11 +187,14 @@ func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchI } return i.delegate.IndexCompany(ctx, &item) case "article": - var item model.Article - if err := i.load(ctx, payload, &item); err != nil { + item, err := i.loadArticle(ctx, payload.AccountID, payload.ID) + if err != nil { return err } - return i.delegate.IndexArticle(ctx, &item) + if item == nil { + return nil + } + return i.delegate.IndexArticle(ctx, item) default: return fmt.Errorf("unsupported search index entity %q", payload.Entity) } @@ -213,6 +219,25 @@ func (i *DurableSearchIndexer) loadMessage(ctx context.Context, accountID, id ui return &item, nil } +func (i *DurableSearchIndexer) loadArticle(ctx context.Context, accountID, id uint) (*model.Article, error) { + if i.db == nil { + return nil, nil + } + var item model.Article + err := i.db.WithContext(ctx). + Preload("Portal"). + Preload("Category"). + 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: "article", 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). diff --git a/internal/service/search_indexer_worker_test.go b/internal/service/search_indexer_worker_test.go index f5b8122d..af083b1e 100644 --- a/internal/service/search_indexer_worker_test.go +++ b/internal/service/search_indexer_worker_test.go @@ -13,6 +13,7 @@ import ( type recordingDurableSearchIndexer struct { indexedConversations []model.Conversation indexedContacts []uint + indexedArticles []model.Article deletedContacts []uint err error } @@ -55,6 +56,9 @@ func (r *recordingDurableSearchIndexer) DeleteCompany(ctx context.Context, accou } func (r *recordingDurableSearchIndexer) IndexArticle(ctx context.Context, article *model.Article) error { + if article != nil { + r.indexedArticles = append(r.indexedArticles, *article) + } return r.err } @@ -122,6 +126,37 @@ func TestDurableSearchIndexerPreloadsConversationContact(t *testing.T) { } } +func TestDurableSearchIndexerPreloadsArticlePortalAndCategory(t *testing.T) { + db := setupServiceTestDB(t) + account := createTestAccount(t, db) + portal := &model.Portal{AccountID: account.ID, Name: "Help Center", Slug: "help-center", Locale: "en"} + if err := db.Create(portal).Error; err != nil { + t.Fatalf("create portal: %v", err) + } + category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Billing", Slug: "billing", Locale: "en"} + if err := db.Create(category).Error; err != nil { + t.Fatalf("create category: %v", err) + } + article := &model.Article{AccountID: account.ID, PortalID: portal.ID, CategoryID: &category.ID, Title: "Billing FAQ", Slug: "billing-faq", Locale: "en", Status: "published"} + if err := db.Create(article).Error; err != nil { + t.Fatalf("create article: %v", err) + } + delegate := &recordingDurableSearchIndexer{} + indexer := NewDurableSearchIndexer(db, nil, delegate) + + if err := indexer.IndexArticle(context.Background(), article); err != nil { + t.Fatalf("index article: %v", err) + } + + if len(delegate.indexedArticles) != 1 { + t.Fatalf("expected one indexed article, got %#v", delegate.indexedArticles) + } + indexed := delegate.indexedArticles[0] + if indexed.Portal.Slug != "help-center" || indexed.Category == nil || indexed.Category.Name != "Billing" { + t.Fatalf("expected preloaded portal/category in indexed article, got portal=%#v category=%#v", indexed.Portal, indexed.Category) + } +} + func TestDurableSearchIndexerRetriesDelegateFailure(t *testing.T) { db := setupServiceTestDB(t) account := createTestAccount(t, db)