diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 4b59a085..820d9a60 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.83 search conversation ordering parity, prepared as `feat(search): align conversation search order`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align conversation search order`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchService conversation search ordering parity. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.84 search agent-sender filter parity, prepared as `feat(search): align agent sender filters`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(search): align agent sender filters`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot SearchService `from=agent:id` message filter parity. No route artifacts change. - 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: DB fallback conversation search now follows Chatwoot `SearchService#filter_conversations` ordering by `conversations.created_at DESC` instead of local `last_activity_at`, while retaining 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: message advanced-search `from=agent:id` filters now match GoChat's stored agent sender variants (`agent`, `user`, `User`) in DB fallback and Meilisearch, while preserving contact/bot sender aliases and retaining P3.83 conversation ordering, P3.82 contact-conversation reindex fanout, P3.81 conversation identity search, P3.80 search time-bound parity, P3.79 advanced-search feature gates, P3.78 message recency, P3.77 current-user inbox access, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - 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.84 search agent-sender filter parity | Implemented for reused SearchAPI message filters: frontend `from=agent:id` now matches GoChat's stored agent sender variants (`agent`, `user`, `User`) in both DB fallback and Meilisearch filters, while contact and bot sender filters also tolerate local/Rails-style casing. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for additional polymorphic sender values, exact Searchkick sender-type indexing, or global `ChatwootApp.advanced_search_allowed?` differences. | Focused SearchFilter, Meilisearch engine, and SearchRepo sender-filter tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.83 search conversation ordering parity | Implemented for DB fallback conversation search: default ordering now matches Chatwoot `SearchService#filter_conversations` with newest `conversations.created_at` first instead of `last_activity_at`. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for Meilisearch Searchkick ordering drift, tie-break behavior, or explicit sort param differences. | Focused SearchRepo ordering test passed; full `go test ./...` passed outside the restricted socket sandbox after retrying an unrelated `internal/worker` SQLite in-memory flake; `git diff --check` passed. No route artifacts change. | | 0 | P3.82 search contact-conversation reindex parity | Implemented for Meilisearch freshness after P3.81: every contact indexing event also loads account-scoped conversations for that contact and indexes them with preloaded contact data, so conversation identity documents follow contact direct updates and import merges. | Keep in Review; reopen from B12 search smoke or fresh reference evidence for bulk fanout batching, delete semantics, or contact-label-only fanout optimization. | Focused ContactService search-index hook test passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 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. | @@ -2713,3 +2714,4 @@ Verification milestone gates: - 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. - 2026-06-07: P3.83 search conversation ordering checkpoint prepared as `feat(search): align conversation search order`; audited Chatwoot `SearchService#filter_conversations` ordering. GoChat DB fallback conversation search now orders by `conversations.created_at DESC, conversations.id DESC` instead of the local conversation-list `last_activity_at` default, while Meilisearch already emits `created_at_ts:desc` for default search sort. Focused SearchRepo ordering tests passed; full `go test ./...` passed outside the restricted socket sandbox after retrying an unrelated `internal/worker` SQLite in-memory flake; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.84 search agent-sender filter checkpoint prepared as `feat(search): align agent sender filters`; audited Chatwoot `SearchService#parse_from_param`, `#apply_sender_filter`, and reused `SearchContactAgentSelector.vue`/SearchAPI message filter callers. GoChat DB fallback and Meilisearch message filters now treat `from=agent:id` as agent/user sender aliases (`agent`, `user`, `User`) and tolerate contact/bot casing aliases, so reused frontend agent filters find locally stored outgoing agent messages. Focused SearchFilter, Meilisearch engine, and SearchRepo sender-filter tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/repository/search_repo.go b/internal/repository/search_repo.go index 93c8b715..e5ced69b 100644 --- a/internal/repository/search_repo.go +++ b/internal/repository/search_repo.go @@ -501,7 +501,7 @@ func applyMessageFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB { if filter.AdvancedSearchEnabled { // Sender and explicit inbox filters are Chatwoot advanced-search filters. if filter.SenderType != "" { - q = q.Where("sender_type = ?", filter.SenderType) + q = q.Where("sender_type IN ?", searchSenderTypeAliases(filter.SenderType)) } if filter.SenderID != nil { q = q.Where("sender_id = ?", *filter.SenderID) @@ -525,6 +525,19 @@ func applyMessageFilters(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB { return q } +func searchSenderTypeAliases(senderType string) []string { + switch strings.ToLower(strings.TrimSpace(senderType)) { + case "agent", "user": + return []string{"agent", "user", "User"} + case "contact": + return []string{"contact", "Contact"} + case "bot": + return []string{"bot", "Bot"} + default: + return []string{senderType} + } +} + func applySearchInboxAccess(q *gorm.DB, filter *RepoSearchFilter) *gorm.DB { if filter == nil || !filter.EnforceInboxAccess { return q diff --git a/internal/repository/search_repo_test.go b/internal/repository/search_repo_test.go index 3b34f2ea..2d6fbde5 100644 --- a/internal/repository/search_repo_test.go +++ b/internal/repository/search_repo_test.go @@ -294,6 +294,33 @@ func TestSearchRepo_SearchMessages_SenderFilterRequiresAdvancedSearch(t *testing assert.Equal(t, contactMsg.ID, results[0].ID) } +func TestSearchRepo_SearchMessages_AgentFromFilterMatchesStoredUserSenderTypes(t *testing.T) { + db := setupTestDB(t) + repo := NewSearchRepo(db) + + account := createTestAccountForSearch(t, db) + conv := createTestConversationForSearch(t, db, account.ID, "open", "") + userID := uint(77) + + upperUserMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "upper user message", "outgoing") + require.NoError(t, db.Model(upperUserMsg).Updates(map[string]any{"sender_type": "User", "sender_id": userID}).Error) + lowerUserMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "lower user message", "outgoing") + require.NoError(t, db.Model(lowerUserMsg).Updates(map[string]any{"sender_type": "user", "sender_id": userID}).Error) + contactMsg := createTestMessageForSearch(t, db, account.ID, conv.ID, "contact message", "incoming") + require.NoError(t, db.Model(contactMsg).Updates(map[string]any{"sender_type": "contact", "sender_id": userID}).Error) + + filter := &search.SearchFilter{Page: 1, PerPage: 25, AdvancedSearchEnabled: true, SenderType: "agent", SenderID: &userID} + results, total, err := repo.SearchMessages(context.Background(), account.ID, "", filter) + + require.NoError(t, err) + assert.Equal(t, int64(2), total) + require.Len(t, results, 2) + ids := []uint{results[0].ID, results[1].ID} + assert.Contains(t, ids, upperUserMsg.ID) + assert.Contains(t, ids, lowerUserMsg.ID) + assert.NotContains(t, ids, contactMsg.ID) +} + // ========== SearchConversations DB tests (PG only for ILIKE) ========== func TestSearchRepo_SearchConversations_EmptyQuery(t *testing.T) { diff --git a/internal/search/engine_meili.go b/internal/search/engine_meili.go index 315ebc89..3ea34074 100644 --- a/internal/search/engine_meili.go +++ b/internal/search/engine_meili.go @@ -214,7 +214,7 @@ func (e *MeiliSearchEngine) filterExpression(accountID uint, docType SearchResul parts = append(parts, fmt.Sprintf("message_type = %q", filter.MessageType)) } if filter.AdvancedSearchEnabled && filter.SenderType != "" { - parts = append(parts, fmt.Sprintf("sender_type = %q", filter.SenderType)) + parts = append(parts, meiliSenderTypeFilter(filter.SenderType)) } if filter.AdvancedSearchEnabled && filter.SenderID != nil { parts = append(parts, fmt.Sprintf("sender_id = %d", *filter.SenderID)) @@ -274,6 +274,31 @@ func (e *MeiliSearchEngine) indexName(docType SearchResultType) string { return e.indexPrefix + strings.ReplaceAll(string(docType), "_", "_") + "s" } +func meiliSenderTypeFilter(senderType string) string { + aliases := senderTypeAliases(senderType) + if len(aliases) == 1 { + return fmt.Sprintf("sender_type = %q", aliases[0]) + } + parts := make([]string, 0, len(aliases)) + for _, alias := range aliases { + parts = append(parts, fmt.Sprintf("sender_type = %q", alias)) + } + return "(" + strings.Join(parts, " OR ") + ")" +} + +func senderTypeAliases(senderType string) []string { + switch strings.ToLower(strings.TrimSpace(senderType)) { + case "agent", "user": + return []string{"agent", "user", "User"} + case "contact": + return []string{"contact", "Contact"} + case "bot": + return []string{"bot", "Bot"} + default: + return []string{senderType} + } +} + func appendListFilter(parts []string, field string, values []string) []string { if len(values) == 0 { return parts diff --git a/internal/search/engine_test.go b/internal/search/engine_test.go index b57b99a6..5cee2f45 100644 --- a/internal/search/engine_test.go +++ b/internal/search/engine_test.go @@ -153,7 +153,25 @@ func TestMeiliSearchEngine_SearchSendsMessageSenderIDFilter(t *testing.T) { _, err := engine.Search(context.Background(), 42, "hello", filter) require.NoError(t, err) - assert.Equal(t, "account_id = 42 AND sender_type = \"contact\" AND sender_id = 77", requestBody["filter"]) + assert.Equal(t, "account_id = 42 AND (sender_type = \"contact\" OR sender_type = \"Contact\") AND sender_id = 77", requestBody["filter"]) +} + +func TestMeiliSearchEngine_SearchSendsAgentSenderAliases(t *testing.T) { + var requestBody map[string]interface{} + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "/indexes/gochat_messages/search", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&requestBody)) + return jsonResponse(http.StatusOK, `{"hits":[],"estimatedTotalHits":0}`), nil + }) + + engine := NewMeiliSearchEngine(EngineConfig{Host: "http://meili.test", IndexPrefix: "gochat_"}) + engine.client.SetTransport(transport) + senderID := uint(77) + filter := &SearchFilter{Page: 1, PerPage: 10, Types: []SearchResultType{ResultTypeMessage}, AdvancedSearchEnabled: true, SenderType: "agent", SenderID: &senderID} + _, err := engine.Search(context.Background(), 42, "hello", filter) + + require.NoError(t, err) + assert.Equal(t, "account_id = 42 AND (sender_type = \"agent\" OR sender_type = \"user\" OR sender_type = \"User\") AND sender_id = 77", requestBody["filter"]) } func TestMeiliSearchEngine_SearchSkipsAdvancedFiltersWhenFeatureDisabled(t *testing.T) { diff --git a/internal/search/search_filter_test.go b/internal/search/search_filter_test.go index a6c091ea..56ee003b 100644 --- a/internal/search/search_filter_test.go +++ b/internal/search/search_filter_test.go @@ -189,3 +189,18 @@ func TestParseSearchFilter_ChatwootSearchParams(t *testing.T) { assert.Equal(t, uint(7), *sf.InboxID) } } + +func TestParseSearchFilter_ChatwootAgentFromParam(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = &http.Request{ + URL: &url.URL{Path: "/search", RawQuery: "from=agent:77"}, + } + + sf := ParseSearchFilter(c) + + assert.Equal(t, "agent", sf.SenderType) + if assert.NotNil(t, sf.SenderID) { + assert.Equal(t, uint(77), *sf.SenderID) + } +}