feat(search): reindex contact conversations

This commit is contained in:
2026-06-07 08:12:48 +08:00
parent 450c8673d8
commit 0affcf53a3
3 changed files with 59 additions and 6 deletions
+8 -4
View File
@@ -49,11 +49,11 @@ Hermes task landing checklist:
## Current Baseline
- Current tracking checkpoint: 2026-06-07 P3.81 search conversation identity parity, prepared as `feat(search): align conversation identity search`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align conversation identity search`.
- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchService conversation search identity-field parity for global/entity search. No route artifacts change.
- Current tracking checkpoint: 2026-06-07 P3.82 search contact-conversation reindex parity, prepared as `feat(search): reindex contact conversations`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): reindex contact conversations`.
- Latest documentation/tooling checkpoint: this tracker update records Meilisearch conversation document freshness after contact identity changes. 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: reused SearchAPI conversation search now follows Chatwoot `SearchService#filter_conversations`: query text matches conversation `display_id` plus contact `name`, `email`, `phone_number`, and `identifier`, and no longer matches conversation labels or message content. Meilisearch conversation documents now index display/contact identity text; durable indexing and `cmd/reindex_search` preload contacts so live Meilisearch stays aligned. Existing 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 remain implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack.
- Worktree status at this implementation checkpoint: contact indexing now also refreshes related conversation documents, so P3.81 Meilisearch conversation identity search stays current when contact `name`, `email`, `phone_number`, or `identifier` changes through direct update or import merge paths. Existing 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 remain implemented. 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.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. |
| 0 | P3.81 search conversation identity parity | Implemented for reused SearchAPI conversation results: DB fallback now searches `display_id` and joined contact identity fields like Chatwoot, excludes label/message-content matches from conversation search, and Meilisearch conversation documents index display/contact identity text. Durable indexing and full reindex preload conversation contacts before document creation. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for exact Searchkick conversation indexing, contact-update fanout reindexing, or admin/all-inbox visibility drift. | Focused SearchRepo, document builder, durable indexer, and reindex package tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.80 search time-bound parity | Implemented for reused SearchAPI advanced time filters: GoChat now caps `since`/`until` like Chatwoot `SearchService#cap_since_time`/`#cap_until_time`, applies conversation/contact DB filters to Unix `last_activity_at`, article DB filters to `updated_at`, message filters to `created_at`, and emits matching Meilisearch filter fields (`last_activity_at_ts`, `updated_at_ts`, `created_at_ts`). | Keep in Review; reopen from B12 search smoke or fresh reference evidence for Searchkick-specific time filters, timezone edge cases beyond Unix seconds, or additional entity types exposed by reused frontend global search. | Focused search service, Meilisearch engine, document builder, and SearchRepo time-filter tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| 0 | P3.79 search advanced-filter gate parity | Implemented for reused SearchAPI filters: GoChat now mirrors Chatwoot `SearchService` by applying `since/until`, message `from`, and message explicit `inbox_id` filters only when account `advanced_search` is enabled. Always-on message recency and user inbox access scopes remain active regardless of the feature gate. DB fallback and Meilisearch filters use the same gate. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for global `ChatwootApp.advanced_search_allowed?` gating, Searchkick advanced message filters, exact `cap_until_time` future bounds, or custom-role search visibility. | Focused search service, Meilisearch engine, and SearchRepo advanced-gate tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
@@ -245,6 +246,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.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. |
| P3.80 search time-bound parity | `internal/search/search_service.go`, `internal/search/engine.go`, `internal/search/engine_meili.go`, `internal/repository/search_repo.go`, search service/engine/repository tests | `reference/chatwoot/app/services/search_service.rb#apply_time_filter`, `reference/chatwoot/app/services/search_service.rb#cap_since_time`, `reference/chatwoot/app/services/search_service.rb#cap_until_time`, `reference/chatwoot/app/services/search_service.rb#filter_conversations`, `reference/chatwoot/app/services/search_service.rb#apply_message_filters`, `reference/chatwoot/app/services/search_service.rb#filter_contacts`, `reference/chatwoot/app/services/search_service.rb#filter_articles` | Advanced search time filters now use the same entity columns and caps as Chatwoot: `DateFrom` is capped to no earlier than `90.days.ago`, `DateTo` is capped to no later than `90.days.from_now`; DB fallback uses `conversations.last_activity_at` and `contacts.last_activity_at` Unix seconds, `messages.created_at`, and `articles.updated_at`; Meilisearch documents/settings carry `last_activity_at_ts`, and Meili filters pick `last_activity_at_ts`, `created_at_ts`, or `updated_at_ts` by entity. | Review by `feat(search): align time filters`; focused tests cover service caps, document `last_activity_at_ts`, Meilisearch field selection, and DB conversation/contact/article field selection; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
| P3.79 search advanced-filter gate parity | `internal/search/search_filter.go`, `internal/search/search_service.go`, `internal/search/engine_meili.go`, `internal/repository/search_repo.go`, search service/engine/repository tests | `reference/chatwoot/app/services/search_service.rb#filter_conversations`, `reference/chatwoot/app/services/search_service.rb#apply_message_filters`, `reference/chatwoot/app/services/search_service.rb#apply_time_filter`, `reference/chatwoot/app/services/search_service.rb#apply_sender_filter`, `reference/chatwoot/app/services/search_service.rb#apply_inbox_id_filter`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | Search filters now honor the same `current_account.feature_enabled?('advanced_search')` gates as Chatwoot: the search service resolves the account feature into the filter; DB fallback skips `since/until` date filters unless advanced search is enabled; message sender (`from`) and explicit `inbox_id` filters are ignored unless advanced search is enabled; Meilisearch omits those advanced filter expressions under the same gate. Message `created_at >= 3.months.ago` and user inbox access scopes remain always-on. | Review by `feat(search): gate advanced filters`; focused tests cover service feature propagation, DB sender filter ignored/enforced by feature state, and Meilisearch omission/emission of sender/date/inbox advanced filters; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. |
@@ -352,6 +354,7 @@ This ledger records the committed parity checkpoints that future slices should b
| Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- |
| `feat(search): reindex contact conversations` | Advances P3.82 with Meilisearch freshness for P3.81 conversation identity search. Contact indexing now also reloads and indexes related account-scoped conversations with contact data preloaded, so contact identity changes made through direct updates or import merges refresh conversation search documents. | `go test ./internal/service -run 'TestContactService_SearchIndexHooks(ReindexContactConversations)?' -count=1`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.82 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(search): align conversation identity search` | Advances P3.81 with Chatwoot `SearchService#filter_conversations` parity. Conversation search now matches `display_id` and contact identity fields (`name`, `email`, `phone_number`, `identifier`) instead of local labels/message-content text; Meilisearch conversation documents index the same display/contact identity text, and durable/full reindex paths preload contacts before creating conversation documents. | `go test ./internal/search ./internal/repository ./internal/service ./cmd/reindex_search -run 'TestDocumentBuildersSetStableUIDAndType\|TestSearchRepo_SearchConversations_(DisplayAndContactMatch\|DoesNotMatchLabelsOrMessages)\|TestDurableSearchIndexerPreloadsConversationContact\|TestNewSearchEngine_DefaultsToMeilisearch' -count=1`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.81 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(search): align time filters` | Advances P3.80 with Chatwoot `SearchService` advanced time-filter parity. GoChat now caps `since` to `90.days.ago` and `until` to `90.days.from_now`, applies conversation/contact search time filters to `last_activity_at`, message filters to `created_at`, and article filters to `updated_at`; Meilisearch documents/settings now include `last_activity_at_ts` and emit entity-specific time filter fields. | `go test ./internal/search ./internal/repository -run 'TestSearchMessages_CapsAdvancedSearchDateRange\|TestMeiliSearchEngine_SearchUsesReferenceTimeFilterFields\|TestDocumentBuildersSetStableUIDAndType\|TestSearchRepo_Search(Conversations\|Contacts\|Articles)_DateFilterUses' -count=1`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.80 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(contacts): default contact type` | Advances P3.74 with Chatwoot contact default/sync parity. Contact persistence now normalizes blank `contact_type` to `visitor`, then upgrades visitor contacts with email, phone number, or social additional attributes to `lead`, so CSV imports without an explicit contact type no longer create empty-type records that drift from CRM v2 lead-only behavior. | `go test ./internal/model ./internal/service -run 'TestContact_Create|TestContactService_ImportCSV_DefaultsContactTypeLikeChatwoot' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.74 to Review; continue Phase 2/3 drift audit for message filters, Phase 6 placeholder audit, or B12 live smoke. |
@@ -2705,3 +2708,4 @@ Verification milestone gates:
- 2026-06-07: P3.79 search advanced-filter gate checkpoint prepared as `feat(search): gate advanced filters`; audited Chatwoot `SearchService` feature gates around conversation/contact/article time filters and message time/sender/inbox filters, plus reused dashboard SearchAPI callers. GoChat search now resolves account `advanced_search` into the search filter, skips `since/until` date predicates unless that feature is enabled, and ignores message `from` plus explicit `inbox_id` filters unless advanced search is enabled; message recency and user inbox access remain always-on. Focused search service/engine/repository tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.80 search time-bound checkpoint prepared as `feat(search): align time filters`; audited Chatwoot `SearchService#apply_time_filter`, `#cap_since_time`, `#cap_until_time`, and the conversation/message/contact/article filter callers. GoChat advanced search time filters now cap `since`/`until` to the same +/-90-day bounds, use `last_activity_at` for conversation/contact DB fallback, `updated_at` for articles, and `created_at` for messages; Meilisearch documents/settings now expose `last_activity_at_ts` and choose the same entity-specific timestamp fields. Focused search service/engine/repository tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change.
- 2026-06-07: P3.81 search conversation identity checkpoint prepared as `feat(search): align conversation identity search`; audited Chatwoot `SearchService#filter_conversations` and reused dashboard SearchAPI callers. GoChat conversation search now matches `display_id` and joined contact identity fields, stops treating labels/message content as conversation-search text, indexes display/contact identity text into Meilisearch conversation documents, and preloads contacts during durable search indexing and full reindex. Focused SearchRepo/search document/durable indexer tests passed; full `go test ./...` passed outside the restricted socket sandbox after one unrelated `internal/worker` SQLite in-memory retry; `git diff --check` passed. No route artifacts change.
- 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.
+18
View File
@@ -63,6 +63,24 @@ func (s *ContactService) SetWorkerPool(wp *worker.WorkerPool) {
func (s *ContactService) indexContact(ctx context.Context, contact *model.Contact) {
if s.searchIndexer != nil {
logSearchIndexError("contact", contact.ID, s.searchIndexer.IndexContact(ctx, contact))
s.indexContactConversations(ctx, contact)
}
}
func (s *ContactService) indexContactConversations(ctx context.Context, contact *model.Contact) {
if s == nil || s.repo == nil || s.searchIndexer == nil || contact == nil || contact.ID == 0 {
return
}
var conversations []model.Conversation
if err := s.repo.DB().WithContext(ctx).
Preload("Contact").
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)
return
}
for i := range conversations {
logSearchIndexError("conversation", conversations[i].ID, s.searchIndexer.IndexConversation(ctx, &conversations[i]))
}
}
+33 -2
View File
@@ -12,12 +12,20 @@ import (
)
type mockServiceSearchIndexer struct {
indexed []string
deleted []string
indexed []string
deleted []string
indexedConversationIDs []uint
indexedConversationNames []string
}
func (m *mockServiceSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error {
m.indexed = append(m.indexed, "conversation")
if conversation != nil {
m.indexedConversationIDs = append(m.indexedConversationIDs, conversation.ID)
if conversation.Contact != nil {
m.indexedConversationNames = append(m.indexedConversationNames, conversation.Contact.Name)
}
}
return nil
}
@@ -124,6 +132,29 @@ func TestContactService_SearchIndexHooks(t *testing.T) {
assert.Equal(t, []string{"contact"}, indexer.deleted)
}
func TestContactService_SearchIndexHooksReindexContactConversations(t *testing.T) {
db := setupServiceTestDB(t)
repo := NewContactService(repository.NewContactRepo(db), nil, nil)
indexer := &mockServiceSearchIndexer{}
repo.SetSearchIndexer(indexer)
account := createTestAccount(t, db)
contact, err := repo.Create(context.Background(), account.ID, CreateContactRequest{Name: "Ada", Email: "ada@example.com"})
require.NoError(t, err)
inbox := createTestInbox(t, db, account.ID, "web_widget")
conversation := createTestConversation(t, db, account.ID, inbox.ID, contact.ID)
indexer.indexed = nil
indexer.indexedConversationIDs = nil
indexer.indexedConversationNames = nil
_, err = repo.Update(context.Background(), account.ID, contact.ID, UpdateContactRequest{Name: "Ada Lovelace"})
require.NoError(t, err)
assert.Equal(t, []string{"contact", "conversation"}, indexer.indexed)
assert.Equal(t, []uint{conversation.ID}, indexer.indexedConversationIDs)
assert.Equal(t, []string{"Ada Lovelace"}, indexer.indexedConversationNames)
}
func TestCompanyService_SearchIndexHooks(t *testing.T) {
db, _, _, _, svc := setupCompanyServiceTest(t)
indexer := &mockServiceSearchIndexer{}