From 50a9160f82257a83b9542b52b861c4cdb7323ee9 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 04:04:41 +0800 Subject: [PATCH] feat(conversations): align search message order --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 +++++++++----- .../api/v1/conversation_handler_crud_test.go | 17 ++++++++++++++--- .../handler/api/v1/conversation_serializer.go | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 38715153..ff1f183d 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.62 conversation search payload-field parity, prepared as `feat(conversations): trim search payload meta`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): trim search payload meta`. -- Latest documentation/tooling checkpoint: this tracker update records that Chatwoot `conversations/search` renders each payload item through the search `_conversation` partial without the normal conversation `meta` object. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.63 conversation search message-order parity, prepared as `feat(conversations): align search message order`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align search message order`. +- Latest documentation/tooling checkpoint: this tracker update records that Chatwoot search `_conversation` renders `conversation.messages` using `Message` default `created_at ASC` ordering. 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: conversation search payload items now omit the normal conversation `meta` object and only return the fields in Chatwoot's search `_conversation` partial, while retaining P3.61 sender-name and P3.60 numeric message-type parity. P3.59 search finder payload parity, P3.58 index finder parity, P3.57/P3.56/P3.55 conversation meta parity, P3.54 unread-count parity, P5.8b contact import DataImportJob parity, P3.53 first-response distribution parity, and prior report/profile/channel/contact 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: conversation search result messages now follow Chatwoot `Message` default ordering by `created_at ASC` with `id ASC` as a deterministic tie-breaker, while retaining P3.62 trimmed payload fields, P3.61 sender-name, and P3.60 numeric message-type parity. P3.59 search finder payload parity, P3.58 index finder parity, P3.57/P3.56/P3.55 conversation meta parity, P3.54 unread-count parity, P5.8b contact import DataImportJob parity, P3.53 first-response distribution parity, and prior report/profile/channel/contact 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 deeper report data-source/metric-builder drift or 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,7 +156,8 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 0 | P3.62 conversation search payload-field parity | Implemented for reused dashboard conversation search results: GoChat now follows `conversations/search.json.jbuilder` plus the search `_conversation` partial by omitting the normal conversation `meta` object from each search payload item. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for message ordering, sender polymorphic types beyond Contact/User, or additional search model serializer fields. | Focused conversation search handler test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.63 conversation search message-order parity | Implemented for reused dashboard conversation search results: GoChat now follows Chatwoot `Message` `default_scope { order(created_at: :asc) }` when serializing `conversation.messages` inside search payload items. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for sender polymorphic types beyond Contact/User or additional search model serializer fields. | Focused conversation search handler test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | +| 0 | P3.62 conversation search payload-field parity | Implemented for reused dashboard conversation search results: GoChat now follows `conversations/search.json.jbuilder` plus the search `_conversation` partial by omitting the normal conversation `meta` object from each search payload item. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for sender polymorphic types beyond Contact/User or additional search model serializer fields. | Focused conversation search handler test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.61 conversation search sender-name parity | Implemented for reused dashboard conversation search results: GoChat now follows inspected `api/v1/models/_conversation.json.jbuilder` by emitting `sender_name` from `message.sender.name` for persisted contact/user senders in search result messages. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for message ordering, sender polymorphic types beyond Contact/User, or additional search model serializer fields. | Focused conversation search handler test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.60 conversation search message-type parity | Implemented for reused dashboard conversation search results: GoChat now follows inspected `api/v1/models/_conversation.json.jbuilder` by serializing each search result message `message_type` as Chatwoot enum integers (`incoming=0`, `outgoing=1`, `activity=2`, `template=3`) instead of local string values. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for message ordering or additional search model serializer fields. | Focused conversation search handler test, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | | 0 | P3.59 conversation search finder parity | Implemented for reused dashboard conversation search calls: GET `/conversations/search` now follows inspected Chatwoot `ConversationsController#search`, `ConversationFinder#perform`, and `search.json.jbuilder`; it uses the finder filters/counts, accepts missing `q`, returns root `{ meta, payload }`, omits `assigned_count`, and emits search-specific contact/inbox/message payloads. | Keep in Review; reopen from B12 conversation search smoke or fresh reference evidence for exact model search serializer drift, PostgreSQL `ILIKE` case-folding drift, or duplicate message join count drift. | Focused conversation search handler tests, full `go test ./...`, and `git diff --check` must pass. No route artifacts change. | @@ -225,6 +226,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.63 conversation search message-order parity | `internal/handler/api/v1/conversation_serializer.go`, conversation search handler tests | `reference/chatwoot/app/views/api/v1/models/_conversation.json.jbuilder`, `reference/chatwoot/app/models/message.rb` | Conversation search result messages now serialize in Chatwoot association order by `created_at ASC`, matching `Message` default scope when the search partial iterates `conversation.messages`. GoChat keeps `id ASC` only as a deterministic tie-breaker for same-timestamp rows. | Review by `feat(conversations): align search message order`; focused search handler tests assert created-at ordering across messages inserted out of chronological order; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.62 conversation search payload-field parity | `internal/handler/api/v1/conversation_serializer.go`, conversation search handler tests | `reference/chatwoot/app/views/api/v1/accounts/conversations/search.json.jbuilder`, `reference/chatwoot/app/views/api/v1/models/_conversation.json.jbuilder` | Conversation search payload items now omit the normal list/show `meta` object, matching the dedicated search partial fields: `id`, `uuid`, `created_at`, `contact`, `inbox`, `messages`, and `account_id`. | Review by `feat(conversations): trim search payload meta`; focused search handler tests assert the root `meta` still exists but each payload item has no `meta`; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.61 conversation search sender-name parity | `internal/handler/api/v1/conversation_serializer.go`, conversation search handler tests | `reference/chatwoot/app/views/api/v1/models/_conversation.json.jbuilder`, `reference/chatwoot/app/views/api/v1/accounts/conversations/search.json.jbuilder` | Conversation search result messages now serialize `sender_name` when the persisted sender can be resolved, matching `json.sender_name message.sender.name if message.sender` for contact and user senders. | Review by `feat(conversations): align search sender names`; focused search handler tests assert contact and user `sender_name`; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | | P3.60 conversation search message-type parity | `internal/handler/api/v1/conversation_serializer.go`, conversation search handler tests | `reference/chatwoot/app/views/api/v1/models/_conversation.json.jbuilder`, `reference/chatwoot/app/views/api/v1/accounts/conversations/search.json.jbuilder` | Conversation search result messages now serialize `message_type` using Chatwoot `message_type_before_type_cast` integer semantics, sharing the existing message-type mapping used by normal message serializers. | Review by `feat(conversations): align search message types`; focused search handler tests assert numeric `message_type`; full `go test ./...` and `git diff --check` must pass. No route artifacts change. | @@ -312,6 +314,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(conversations): align search message order` | Advances P3.63 with Chatwoot conversation search message-order parity. Search result messages now use `created_at ASC, id ASC`, matching the reference `_conversation` partial iterating `conversation.messages` under `Message` default `created_at ASC` scope while keeping deterministic ties. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestSearch' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search)\|ConversationCrudTestSuite/Test(Search\|List)' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.63 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): trim search payload meta` | Advances P3.62 with Chatwoot conversation search payload-field parity. Search result payload items now use only the dedicated search `_conversation` partial fields and no longer include the normal list/show `meta` object, while preserving the root search `meta` counts and P3.61/P3.60 message fields. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestSearch' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search)\|ConversationCrudTestSuite/Test(Search\|List)' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.62 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align search sender names` | Advances P3.61 with Chatwoot conversation search model serializer parity. Search result messages now resolve persisted contact/user senders and emit `sender_name` using the same `message.sender.name if message.sender` behavior as the reference `_conversation` Jbuilder, while preserving P3.60 numeric `message_type` and P3.59 root search payload behavior. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestSearch' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search)\|ConversationCrudTestSuite/Test(Search\|List)' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.61 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align search message types` | Advances P3.60 with Chatwoot conversation search message serializer parity. Search result messages now emit integer `message_type` values matching `message_type_before_type_cast`, instead of GoChat string enums, while preserving the P3.59 root search payload and finder behavior. | `go test ./internal/handler/api/v1 -run 'ConversationCrudTestSuite/TestSearch' -count=1`; `go test ./internal/repository ./internal/service ./internal/handler/api/v1 -run 'ConversationRepo_(GetMeta\|ListForFinder)\|Conversation.*(Meta\|List\|Search)\|ConversationCrudTestSuite/Test(Search\|List)' -count=1`; `go test ./...`; `git diff --check`. No route artifacts change. | Move P3.60 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2629,3 +2632,4 @@ Verification milestone gates: - 2026-06-07: P3.60 conversation search message-type checkpoint prepared as `feat(conversations): align search message types`; audited Chatwoot search `_conversation` model serializer and its `message_type_before_type_cast` field. GoChat conversation search result messages now emit integer message types (`incoming=0`, `outgoing=1`, `activity=2`, `template=3`) instead of local string enums. Focused search handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.61 conversation search sender-name checkpoint prepared as `feat(conversations): align search sender names`; audited Chatwoot search `_conversation` model serializer and its `json.sender_name message.sender.name if message.sender` field. GoChat conversation search result messages now resolve persisted contact/user senders and emit `sender_name` alongside the existing numeric `message_type` payload. Focused search handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.62 conversation search payload-field checkpoint prepared as `feat(conversations): trim search payload meta`; audited Chatwoot `conversations/search.json.jbuilder` and the dedicated search `_conversation` partial. GoChat search payload items now omit the normal conversation `meta` object and keep only the search partial fields while preserving root search `meta` counts. Focused search handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.63 conversation search message-order checkpoint prepared as `feat(conversations): align search message order`; audited Chatwoot search `_conversation` partial and `Message` default scope. GoChat search result messages now order by `created_at ASC, id ASC` instead of `id ASC`, matching `conversation.messages` chronological rendering while keeping deterministic ties. Focused search handler tests passed; full `go test ./...` and `git diff --check` passed. No route artifacts change. diff --git a/internal/handler/api/v1/conversation_handler_crud_test.go b/internal/handler/api/v1/conversation_handler_crud_test.go index bb0064bb..d66336ac 100644 --- a/internal/handler/api/v1/conversation_handler_crud_test.go +++ b/internal/handler/api/v1/conversation_handler_crud_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strconv" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -898,7 +899,10 @@ func (s *ConversationCrudTestSuite) TestUpdateLabels_NotFound() { // ========== Search Handler Tests ========== func (s *ConversationCrudTestSuite) TestSearch_Success() { - s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, SenderID: &s.testContact.ID, SenderType: string(model.SenderTypeContact), Content: "needle open", MessageType: string(model.MessageTypeIncoming)}).Error) + newer := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC) + older := newer.Add(-time.Hour) + s.Require().NoError(s.db.Create(&model.Message{Base: model.Base{CreatedAt: newer}, AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, SenderID: &s.testContact.ID, SenderType: string(model.SenderTypeContact), Content: "needle open", MessageType: string(model.MessageTypeIncoming)}).Error) + s.Require().NoError(s.db.Create(&model.Message{Base: model.Base{CreatedAt: older}, AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, SenderID: &s.testContact.ID, SenderType: string(model.SenderTypeContact), Content: "older context", MessageType: string(model.MessageTypeIncoming)}).Error) agent := &model.User{AccountID: s.testAccount.ID, Name: "Search Agent", Email: "search-agent-" + strconv.FormatUint(uint64(s.testAccount.ID), 10) + "@example.com", Password: "secret", Role: "agent"} s.Require().NoError(s.db.Create(agent).Error) resolved := &model.Conversation{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ContactID: s.testContact.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"} @@ -942,15 +946,22 @@ func (s *ConversationCrudTestSuite) TestSearch_Success() { assert.Equal(s.T(), s.testContact.Name, resp.Payload[0].Contact["name"]) assert.Equal(s.T(), s.testInbox.Name, resp.Payload[0].Inbox["name"]) assert.NotEmpty(s.T(), resp.Payload[0].Messages) - assert.Equal(s.T(), float64(0), resp.Payload[0].Messages[0]["message_type"]) senderNamesByContent := map[string]string{} + messageTypesByContent := map[string]float64{} + contentsByConversation := map[uint][]string{} for _, conversation := range resp.Payload { for _, message := range conversation.Messages { + content := message["content"].(string) + contentsByConversation[conversation.ID] = append(contentsByConversation[conversation.ID], content) + messageTypesByContent[content] = message["message_type"].(float64) if senderName, ok := message["sender_name"].(string); ok { - senderNamesByContent[message["content"].(string)] = senderName + senderNamesByContent[content] = senderName } } } + assert.Equal(s.T(), []string{"older context", "needle open"}, contentsByConversation[s.testConv.ID]) + assert.Equal(s.T(), float64(0), messageTypesByContent["needle open"]) + assert.Equal(s.T(), float64(1), messageTypesByContent["needle resolved"]) assert.Equal(s.T(), s.testContact.Name, senderNamesByContent["needle open"]) assert.Equal(s.T(), agent.Name, senderNamesByContent["needle resolved"]) } diff --git a/internal/handler/api/v1/conversation_serializer.go b/internal/handler/api/v1/conversation_serializer.go index ed970e01..7797560d 100644 --- a/internal/handler/api/v1/conversation_serializer.go +++ b/internal/handler/api/v1/conversation_serializer.go @@ -180,7 +180,7 @@ func serializeConversationSearch(ctx context.Context, db *gorm.DB, conversation var messages []model.Message if err := db.WithContext(ctx). Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID). - Order("id ASC"). + Order("created_at ASC, id ASC"). Find(&messages).Error; err == nil { payload.Messages = make([]chatwootConversationSearchMessage, 0, len(messages)) for i := range messages {