diff --git a/cmd/reindex_search/main.go b/cmd/reindex_search/main.go index d9890458..5b18e165 100644 --- a/cmd/reindex_search/main.go +++ b/cmd/reindex_search/main.go @@ -139,7 +139,7 @@ func reindexConversations(ctx context.Context, engine search.SearchEngine, db *g var lastID uint for { var rows []model.Conversation - q := db.WithContext(ctx).Where("id > ?", lastID).Order("id ASC").Limit(batchSize) + q := db.WithContext(ctx).Preload("Contact").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 18847b92..af76d012 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.80 search time-bound parity, prepared as `feat(search): align time filters`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align time filters`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchService entity-specific time filter columns and 90-day cap parity for global/entity search. No route artifacts change. +- 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. - 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 advanced time filters now follow Chatwoot `SearchService`: `since` is capped to `90.days.ago`, `until` is capped to `90.days.from_now`, conversations and contacts filter by `last_activity_at`, articles filter by `updated_at`, and messages filter by `created_at`. Meilisearch documents/settings now expose `last_activity_at_ts` for conversation/contact filtering while keeping P3.79 advanced-search feature gates, P3.78 message recency, P3.77 current-user inbox access, and prior checkpoints implemented. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- 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. - 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.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. | | 0 | P3.78 search message recency parity | Implemented for reused global/entity message search: GoChat now applies Chatwoot `SearchService#message_base_query`'s recent-message base relation (`created_at >= 3.months.ago`) before message content search and filters. The DB fallback uses `messages.created_at`; Meilisearch message searches add the same `created_at_ts` lower bound, while contact/conversation/article search remains unaffected. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for exact Rails `3.months.ago` calendar edge cases, `cap_until_time` future bounds, or advanced-search feature-gated date filter drift. | Focused search service, Meilisearch engine, and SearchRepo message-recency tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -244,6 +245,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.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. | | P3.78 search message recency 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#message_base_query`, `reference/chatwoot/app/services/search_service.rb#filter_messages_with_like`, `reference/chatwoot/app/services/search_service.rb#filter_messages_with_gin`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | Global/entity message search now follows Chatwoot's recent-message base relation: the search service stamps message search filters with a `3.months.ago` lower bound, DB fallback applies it to `messages.created_at` before content/filter/date predicates, Meilisearch message searches apply `created_at_ts >= cutoff`, and older explicit `since` params cannot expand the message result set beyond the reference base query. | Review by `feat(search): scope message recency`; focused tests cover service cutoff stamping, DB exclusion of older messages, and Meilisearch message filter emission; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | @@ -350,6 +352,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `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. | | `feat(conversations): scope filter permissions` | Advances P3.73 with Chatwoot `Conversations::PermissionFilterService` parity for `POST /conversations/filter`. Advanced filters now apply assigned-inbox visibility before filter payloads and meta counts, so non-admin agents cannot see conversations from inboxes they do not belong to while administrators retain account-wide visibility. | `go test ./internal/service -run 'TestConversationService_Filter_(RestrictsAgentToAssignedInboxes|AllowsAdministratorAllInboxes)' -count=1`; full `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.73 to Review; continue Phase 2/3 drift audit for message filters, import/contact-type drift, Phase 6 placeholder audit, or B12 live smoke. | @@ -2701,3 +2704,4 @@ Verification milestone gates: - 2026-06-07: P3.78 search message recency checkpoint prepared as `feat(search): scope message recency`; audited Chatwoot `SearchService#message_base_query`, message search SQL/GIN branches, and reused dashboard SearchAPI callers. GoChat global/entity message search now stamps message filters with the Chatwoot `3.months.ago` lower bound, applies it to DB fallback `messages.created_at`, emits the same `created_at_ts` lower bound for Meilisearch message searches, and keeps the recent base relation even when older `since` params are provided. 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.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. diff --git a/internal/model/conversation.go b/internal/model/conversation.go index 7c8730c9..91050169 100644 --- a/internal/model/conversation.go +++ b/internal/model/conversation.go @@ -22,12 +22,12 @@ type Conversation struct { TeamID *uint `gorm:"index" json:"team_id,omitempty"` CampaignID *uint `gorm:"index" json:"campaign_id,omitempty"` SlaPolicyID *uint `gorm:"index" json:"sla_policy_id,omitempty"` - Status string `gorm:"size:50;index;default:open" json:"status"` // open, resolved, pending, snoozed - Priority string `gorm:"size:50;default:none" json:"priority"` // none, low, medium, high, urgent + Status string `gorm:"size:50;index;default:open" json:"status"` // open, resolved, pending, snoozed + Priority string `gorm:"size:50;default:none" json:"priority"` // none, low, medium, high, urgent ChannelType string `gorm:"size:50;not null" json:"channel_type"` - Channel string `gorm:"size:50;not null" json:"channel"` // channel identifier for routing - Labels string `gorm:"type:text" json:"labels,omitempty"` // comma-separated or JSON label list - SnoozedUntil *int64 `gorm:"index" json:"snoozed_until,omitempty"` // timestamp for snoozed conversations + Channel string `gorm:"size:50;not null" json:"channel"` // channel identifier for routing + Labels string `gorm:"type:text" json:"labels,omitempty"` // comma-separated or JSON label list + SnoozedUntil *int64 `gorm:"index" json:"snoozed_until,omitempty"` // timestamp for snoozed conversations AdditionalAttributes datatypes.JSON `gorm:"type:jsonb" json:"additional_attributes,omitempty"` CustomAttributes datatypes.JSON `gorm:"type:jsonb" json:"custom_attributes,omitempty"` AgentLastSeenAt *int64 `json:"agent_last_seen_at,omitempty"` @@ -38,9 +38,11 @@ type Conversation struct { FirstReplyCreatedAt *int64 `json:"first_reply_created_at,omitempty"` Muted bool `gorm:"default:false" json:"muted"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` // Chatwoot: timestamp when conversation was resolved - ResumedAt *time.Time `json:"resumed_at,omitempty"` // Chatwoot: timestamp when conversation was reopened + ResumedAt *time.Time `json:"resumed_at,omitempty"` // Chatwoot: timestamp when conversation was reopened LastMessageAt *int64 `gorm:"index" json:"last_message_at,omitempty"` LastNonSysMsgAt *int64 `json:"last_non_system_message_at,omitempty"` + + Contact *Contact `gorm:"foreignKey:ContactID" json:"contact,omitempty"` } // BeforeCreate auto-generates UUID before inserting a new Conversation record. @@ -51,4 +53,4 @@ func (c *Conversation) BeforeCreate(tx *gorm.DB) error { return nil } -func (Conversation) TableName() string { return "conversations" } \ No newline at end of file +func (Conversation) TableName() string { return "conversations" } diff --git a/internal/repository/search_repo.go b/internal/repository/search_repo.go index 610ebebd..ccfccd71 100644 --- a/internal/repository/search_repo.go +++ b/internal/repository/search_repo.go @@ -138,7 +138,7 @@ func NewSearchRepo(db *gorm.DB) *SearchRepo { // SearchConversations searches conversations with advanced filters. // This method satisfies search.SearchRepoInterface by accepting *search.SearchFilter // and converting it to *RepoSearchFilter internally. -// Reference: Chatwoot conversations#index with filter params (status, assignee_id, labels, etc.) +// Reference: Chatwoot SearchService#filter_conversations. func (r *SearchRepo) SearchConversations(ctx context.Context, accountID uint, query string, filter *search.SearchFilter) ([]model.Conversation, int64, error) { repoFilter := searchFilterToRepo(filter) return r.searchConversationsInternal(ctx, accountID, query, repoFilter) @@ -149,25 +149,39 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID var conversations []model.Conversation var total int64 - q := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID) + q := r.db.WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID) - // Apply text search — ILIKE (default) or pg_trgm fuzzy match + // Chatwoot conversation search matches display_id and contact identity fields. if query != "" { if filter.IsTrigram() { - // pg_trgm fuzzy match: uses % operator with similarity threshold - // Requires pg_trgm extension and GIN indexes on searchable columns. - // Default similarity threshold is 0.3 (set_show_trgm). - q = q.Where( - "labels % ? OR EXISTS (SELECT 1 FROM messages WHERE messages.conversation_id = conversations.id AND messages.content % ? AND messages.account_id = ?)", - query, query, accountID, + q = q.Joins("INNER JOIN contacts ON conversations.contact_id = contacts.id").Where( + "CAST(conversations.display_id AS TEXT) % ? OR contacts.name % ? OR contacts.email % ? OR contacts.phone_number % ? OR contacts.identifier % ?", + query, query, query, query, query, ) } else { - // ILIKE substring match (default, works without pg_trgm) likeQuery := "%" + query + "%" - q = q.Where( - "labels ILIKE ? OR EXISTS (SELECT 1 FROM messages WHERE messages.conversation_id = conversations.id AND messages.content ILIKE ? AND messages.account_id = ?)", - likeQuery, likeQuery, accountID, - ) + if r.db.Dialector.Name() == "sqlite" { + likeQuery = "%" + strings.ToLower(query) + "%" + q = q.Where( + `EXISTS ( + SELECT 1 FROM contacts + WHERE contacts.id = conversations.contact_id + AND contacts.account_id = conversations.account_id + AND (LOWER(CAST(conversations.display_id AS TEXT)) LIKE ? OR LOWER(contacts.name) LIKE ? OR LOWER(contacts.email) LIKE ? OR LOWER(contacts.phone_number) LIKE ? OR LOWER(contacts.identifier) LIKE ?) + )`, + likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, + ) + } else { + q = q.Where( + `EXISTS ( + SELECT 1 FROM contacts + WHERE contacts.id = conversations.contact_id + AND contacts.account_id = conversations.account_id + AND (CAST(conversations.display_id AS TEXT) ILIKE ? OR contacts.name ILIKE ? OR contacts.email ILIKE ? OR contacts.phone_number ILIKE ? OR contacts.identifier ILIKE ?) + )`, + likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, + ) + } } } @@ -176,7 +190,7 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID q = applyConversationFilters(q, filter) // Apply date range filter - q = applyDateRangeFilter(q, filter, "last_activity_at", true) + q = applyDateRangeFilter(q, filter, "conversations.last_activity_at", true) // Count if err := q.Count(&total).Error; err != nil { @@ -187,7 +201,7 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID orderClause := filter.OrderClause() // Conversations default to last_activity_at DESC NULLS LAST if filter.SortBy == "created_at" || filter.SortBy == "" { - orderClause = "last_activity_at DESC NULLS LAST, id DESC" + orderClause = "conversations.last_activity_at DESC NULLS LAST, conversations.id DESC" } err := q.Offset(filter.Offset()).Limit(filter.PerPage). diff --git a/internal/repository/search_repo_test.go b/internal/repository/search_repo_test.go index 2c3dc620..5a87f118 100644 --- a/internal/repository/search_repo_test.go +++ b/internal/repository/search_repo_test.go @@ -317,20 +317,49 @@ func TestSearchRepo_SearchConversations_EmptyQuery(t *testing.T) { assert.True(t, found, "expected conversation %d in results", conv.ID) } -func TestSearchRepo_SearchConversations_LabelMatch(t *testing.T) { - skipIfSQLite(t) +func TestSearchRepo_SearchConversations_DisplayAndContactMatch(t *testing.T) { db := setupTestDB(t) repo := NewSearchRepo(db) account := createTestAccountForSearch(t, db) - _ = createTestConversationForSearch(t, db, account.ID, "open", "support,billing") - _ = createTestConversationForSearch(t, db, account.ID, "resolved", "sales,marketing") + contact := createTestContactForSearch(t, db, account.ID, "Ada Lovelace", "ada@example.com", "+123") + otherContact := createTestContactForSearch(t, db, account.ID, "Grace Hopper", "grace@example.com", "+456") + match := createTestConversationForSearch(t, db, account.ID, "open", "support,billing") + displayID := uint(4242) + require.NoError(t, db.Model(match).Updates(map[string]any{"contact_id": contact.ID, "display_id": displayID}).Error) + other := createTestConversationForSearch(t, db, account.ID, "resolved", "ada-label") + require.NoError(t, db.Model(other).Update("contact_id", otherContact.ID).Error) filter := &search.SearchFilter{Page: 1, PerPage: 25} - results, total, err := repo.SearchConversations(context.Background(), account.ID, "billing", filter) + results, total, err := repo.SearchConversations(context.Background(), account.ID, "ada", filter) assert.NoError(t, err) assert.Equal(t, int64(1), total) assert.Len(t, results, 1) + assert.Equal(t, match.ID, results[0].ID) + + results, total, err = repo.SearchConversations(context.Background(), account.ID, "424", filter) + assert.NoError(t, err) + assert.Equal(t, int64(1), total) + assert.Len(t, results, 1) + assert.Equal(t, match.ID, results[0].ID) +} + +func TestSearchRepo_SearchConversations_DoesNotMatchLabelsOrMessages(t *testing.T) { + db := setupTestDB(t) + repo := NewSearchRepo(db) + + account := createTestAccountForSearch(t, db) + contact := createTestContactForSearch(t, db, account.ID, "No Match", "nomatch@example.com", "") + conv := createTestConversationForSearch(t, db, account.ID, "open", "billing") + require.NoError(t, db.Model(conv).Update("contact_id", contact.ID).Error) + _ = createTestMessageForSearch(t, db, account.ID, conv.ID, "billing message", "incoming") + + filter := &search.SearchFilter{Page: 1, PerPage: 25} + results, total, err := repo.SearchConversations(context.Background(), account.ID, "billing", filter) + + require.NoError(t, err) + assert.Equal(t, int64(0), total) + assert.Empty(t, results) } func TestSearchRepo_SearchConversations_WithStatusFilter(t *testing.T) { diff --git a/internal/search/engine.go b/internal/search/engine.go index c2f58b58..c48b0698 100644 --- a/internal/search/engine.go +++ b/internal/search/engine.go @@ -142,12 +142,13 @@ func timestampPtr(value *int64) int64 { func ConversationDocument(conv model.Conversation) SearchDocument { inboxID := conv.InboxID + content := conversationSearchContent(conv) doc := SearchDocument{ ID: conv.ID, Type: ResultTypeConversation, AccountID: conv.AccountID, - Title: fmt.Sprintf("Conversation #%d", conv.ID), - Content: conv.Labels, + Title: conversationSearchTitle(conv), + Content: content, Status: conv.Status, Priority: conv.Priority, Labels: splitLabels(conv.Labels), @@ -165,6 +166,24 @@ func ConversationDocument(conv model.Conversation) SearchDocument { return doc } +func conversationSearchTitle(conv model.Conversation) string { + if conv.DisplayID != nil && *conv.DisplayID != 0 { + return fmt.Sprintf("Conversation #%d", *conv.DisplayID) + } + return fmt.Sprintf("Conversation #%d", conv.ID) +} + +func conversationSearchContent(conv model.Conversation) string { + parts := make([]string, 0, 5) + if conv.DisplayID != nil && *conv.DisplayID != 0 { + parts = append(parts, fmt.Sprintf("%d", *conv.DisplayID)) + } + if conv.Contact != nil { + parts = append(parts, conv.Contact.Name, conv.Contact.Email, conv.Contact.PhoneNumber, conv.Contact.Identifier) + } + return strings.TrimSpace(strings.Join(parts, " ")) +} + func MessageDocument(msg model.Message) SearchDocument { inboxID := msg.InboxID conversationID := msg.ConversationID diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go index 74a4bd58..b57b99a6 100644 --- a/internal/search/engine_test.go +++ b/internal/search/engine_test.go @@ -48,6 +48,9 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) { conv.InboxID = 7 conv.ContactID = 9 conv.LastActivityAt = &lastActivity + displayID := uint(42) + conv.DisplayID = &displayID + conv.Contact = &model.Contact{Name: "Ada Lovelace", Email: "ada@example.com", PhoneNumber: "+123", Identifier: "ada-id"} doc := ConversationDocument(conv) assert.Equal(t, "3:conversation:12", doc.UID) @@ -55,6 +58,8 @@ func TestDocumentBuildersSetStableUIDAndType(t *testing.T) { assert.Equal(t, uint(3), doc.AccountID) assert.Equal(t, []string{"billing", "urgent"}, doc.Labels) assert.Equal(t, lastActivity, doc.LastActivityAtTS) + assert.Equal(t, "Conversation #42", doc.Title) + assert.Equal(t, "42 Ada Lovelace ada@example.com +123 ada-id", doc.Content) } func TestContactDocumentSetsResolvedScopeFields(t *testing.T) { diff --git a/internal/service/search_indexer_worker.go b/internal/service/search_indexer_worker.go index a4120726..de63435b 100644 --- a/internal/service/search_indexer_worker.go +++ b/internal/service/search_indexer_worker.go @@ -41,7 +41,14 @@ func (i *DurableSearchIndexer) IndexConversation(ctx context.Context, conversati return nil } return i.enqueueOrIndex(ctx, "conversation", conversation.AccountID, conversation.ID, func() error { - return i.delegate.IndexConversation(ctx, conversation) + enriched, err := i.loadConversation(ctx, conversation.AccountID, conversation.ID) + if err != nil { + return err + } + if enriched == nil { + return nil + } + return i.delegate.IndexConversation(ctx, enriched) }) } @@ -144,11 +151,14 @@ func (i *DurableSearchIndexer) perform(ctx context.Context, job *model.Backgroun func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchIndexJob) error { switch payload.Entity { case "conversation": - var item model.Conversation - if err := i.load(ctx, payload, &item); err != nil { + item, err := i.loadConversation(ctx, payload.AccountID, payload.ID) + if err != nil { return err } - return i.delegate.IndexConversation(ctx, &item) + if item == nil { + return nil + } + return i.delegate.IndexConversation(ctx, item) case "message": var item model.Message if err := i.load(ctx, payload, &item); err != nil { @@ -178,6 +188,21 @@ func (i *DurableSearchIndexer) performIndex(ctx context.Context, payload searchI } } +func (i *DurableSearchIndexer) loadConversation(ctx context.Context, accountID, id uint) (*model.Conversation, error) { + var item model.Conversation + err := i.db.WithContext(ctx). + Preload("Contact"). + 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: "conversation", AccountID: accountID, ID: id}) + } + if err != nil { + return nil, err + } + return &item, nil +} + func (i *DurableSearchIndexer) load(ctx context.Context, payload searchIndexJob, dest any) error { err := i.db.WithContext(ctx).Where("id = ? AND account_id = ?", payload.ID, payload.AccountID).First(dest).Error if errors.Is(err, gorm.ErrRecordNotFound) { diff --git a/internal/service/search_indexer_worker_test.go b/internal/service/search_indexer_worker_test.go index ced2e489..f5b8122d 100644 --- a/internal/service/search_indexer_worker_test.go +++ b/internal/service/search_indexer_worker_test.go @@ -11,12 +11,16 @@ import ( ) type recordingDurableSearchIndexer struct { - indexedContacts []uint - deletedContacts []uint - err error + indexedConversations []model.Conversation + indexedContacts []uint + deletedContacts []uint + err error } func (r *recordingDurableSearchIndexer) IndexConversation(ctx context.Context, conversation *model.Conversation) error { + if conversation != nil { + r.indexedConversations = append(r.indexedConversations, *conversation) + } return r.err } @@ -91,6 +95,33 @@ func TestDurableSearchIndexerQueuesAndReplaysContactIndex(t *testing.T) { } } +func TestDurableSearchIndexerPreloadsConversationContact(t *testing.T) { + db := setupServiceTestDB(t) + account := createTestAccount(t, db) + contact := &model.Contact{AccountID: account.ID, Name: "Ada Lovelace", Email: "ada@example.com"} + if err := db.Create(contact).Error; err != nil { + t.Fatalf("create contact: %v", err) + } + conversation := &model.Conversation{AccountID: account.ID, ContactID: contact.ID, InboxID: 1, ChannelType: "web_widget", Channel: "web_widget", Status: "open"} + if err := db.Create(conversation).Error; err != nil { + t.Fatalf("create conversation: %v", err) + } + delegate := &recordingDurableSearchIndexer{} + indexer := NewDurableSearchIndexer(db, nil, delegate) + + if err := indexer.IndexConversation(context.Background(), conversation); err != nil { + t.Fatalf("index conversation: %v", err) + } + + if len(delegate.indexedConversations) != 1 { + t.Fatalf("expected one indexed conversation, got %#v", delegate.indexedConversations) + } + indexed := delegate.indexedConversations[0] + if indexed.Contact == nil || indexed.Contact.Name != "Ada Lovelace" || indexed.Contact.Email != "ada@example.com" { + t.Fatalf("expected preloaded contact in indexed conversation, got %#v", indexed.Contact) + } +} + func TestDurableSearchIndexerRetriesDelegateFailure(t *testing.T) { db := setupServiceTestDB(t) account := createTestAccount(t, db)