From 7b8bca6034b38f2cb067c069b902de0e624d79bc Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 08:21:17 +0800 Subject: [PATCH] feat(search): align conversation search order --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 12 ++++++++---- internal/repository/search_repo.go | 4 ++-- internal/repository/search_repo_test.go | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 5f7ad9b3..4b59a085 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.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. +- 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. - 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: 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. +- 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. - 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.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. | | 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. | @@ -246,6 +247,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.83 search conversation ordering parity | `internal/repository/search_repo.go`, `internal/repository/search_repo_test.go` | `reference/chatwoot/app/services/search_service.rb#filter_conversations` | Conversation DB fallback search default ordering now uses `conversations.created_at DESC, conversations.id DESC`, matching Chatwoot `@conversations = conversations_query.order('conversations.created_at DESC')`, instead of the older local `last_activity_at DESC` conversation-list default. | Review by `feat(search): align conversation search order`; focused test proves created-at ordering wins even when `last_activity_at` points the other way; full `go test ./...` passed outside the restricted socket sandbox after retrying an unrelated `internal/worker` SQLite in-memory flake; `git diff --check` passed. No route artifacts change. | | P3.82 search contact-conversation reindex parity | `internal/service/contact_service.go`, `internal/service/search_indexer_hooks_test.go` | `reference/chatwoot/app/services/search_service.rb#filter_conversations`, P3.81 Meilisearch conversation document contract | Contact search indexing now fans out to related account-scoped conversations after the contact document is indexed. Each related conversation is loaded with `Contact` preloaded and passed to `IndexConversation`, keeping Meilisearch conversation identity text aligned with contact direct updates and import merge updates. | Review by `feat(search): reindex contact conversations`; focused test proves contact update indexes both the contact and its conversation with the updated contact name; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | P3.81 search conversation identity parity | `internal/repository/search_repo.go`, `internal/search/engine.go`, `internal/model/conversation.go`, `internal/service/search_indexer_worker.go`, `cmd/reindex_search/main.go`, repository/search/service tests | `reference/chatwoot/app/services/search_service.rb#filter_conversations`, `reference/chatwoot/app/javascript/dashboard/api/search.js`, `reference/chatwoot/app/javascript/dashboard/api/specs/search.spec.js` | Conversation global/entity search now matches Chatwoot's identity search contract: DB fallback matches `CAST(conversations.display_id AS TEXT)` plus joined contact `name`, `email`, `phone_number`, and `identifier`, while label and message-content text are not conversation-search matches. Meilisearch conversation documents use display/contact identity text in `title`/`content`; the conversation model exposes a `Contact` relation for preload; durable search indexing and full reindex preload contacts before indexing conversation documents. | Review by `feat(search): align conversation identity search`; focused tests cover DB display/contact matching, DB label/message exclusion, Meilisearch document content, and durable contact preload; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 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. | @@ -354,6 +356,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(search): align conversation search order` | Advances P3.83 with Chatwoot `SearchService#filter_conversations` ordering parity. DB fallback conversation search now defaults to `conversations.created_at DESC` with deterministic `id DESC`, instead of reusing the normal conversation list `last_activity_at` ordering. | `go test ./internal/repository -run 'TestSearchRepo_SearchConversations_DefaultOrderUsesCreatedAt\|TestSearchRepo_SearchConversations_(DisplayAndContactMatch\|DoesNotMatchLabelsOrMessages)' -count=1`; full `go test ./...` passed outside the restricted socket sandbox after retrying an unrelated `internal/worker` SQLite in-memory flake; `git diff --check` passed. No route artifacts change. | Move P3.83 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `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. | @@ -2709,3 +2712,4 @@ Verification milestone gates: - 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. +- 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. diff --git a/internal/repository/search_repo.go b/internal/repository/search_repo.go index ccfccd71..93c8b715 100644 --- a/internal/repository/search_repo.go +++ b/internal/repository/search_repo.go @@ -199,9 +199,9 @@ func (r *SearchRepo) searchConversationsInternal(ctx context.Context, accountID // Apply sort + pagination orderClause := filter.OrderClause() - // Conversations default to last_activity_at DESC NULLS LAST + // Chatwoot SearchService#filter_conversations orders by created_at DESC. if filter.SortBy == "created_at" || filter.SortBy == "" { - orderClause = "conversations.last_activity_at DESC NULLS LAST, conversations.id DESC" + orderClause = "conversations.created_at DESC, 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 5a87f118..3b34f2ea 100644 --- a/internal/repository/search_repo_test.go +++ b/internal/repository/search_repo_test.go @@ -362,6 +362,29 @@ func TestSearchRepo_SearchConversations_DoesNotMatchLabelsOrMessages(t *testing. assert.Empty(t, results) } +func TestSearchRepo_SearchConversations_DefaultOrderUsesCreatedAt(t *testing.T) { + db := setupTestDB(t) + repo := NewSearchRepo(db) + + account := createTestAccountForSearch(t, db) + older := createTestConversationForSearch(t, db, account.ID, "open", "") + newer := createTestConversationForSearch(t, db, account.ID, "open", "") + base := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC) + olderActivity := base.Add(time.Hour).Unix() + newerActivity := base.Add(-time.Hour).Unix() + require.NoError(t, db.Model(older).Updates(map[string]any{"created_at": base.Add(-time.Hour), "last_activity_at": olderActivity}).Error) + require.NoError(t, db.Model(newer).Updates(map[string]any{"created_at": base.Add(time.Hour), "last_activity_at": newerActivity}).Error) + + filter := &search.SearchFilter{Page: 1, PerPage: 25} + results, total, err := repo.SearchConversations(context.Background(), account.ID, "", filter) + + require.NoError(t, err) + assert.Equal(t, int64(2), total) + require.Len(t, results, 2) + assert.Equal(t, newer.ID, results[0].ID) + assert.Equal(t, older.ID, results[1].ID) +} + func TestSearchRepo_SearchConversations_WithStatusFilter(t *testing.T) { skipIfSQLite(t) db := setupTestDB(t)