diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 9f2da5d3..dc9f0012 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,10 +16,10 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after `1fa46fa feat(conversations): align transcript responses`, with this implementation checkpoint prepared as `feat(conversations): align custom attribute response`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align custom attribute response`. +- Current tracking checkpoint: 2026-06-06 after `7425e26 feat(conversations): align custom attribute response`, with this implementation checkpoint prepared as `feat(contacts): align active pagination payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(contacts): align active pagination payloads`. - Latest documentation-only checkpoint: `8b378c7 docs: refresh parity tracker baseline`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: Phase 6 conversation custom attribute response drift is closed for the reused dashboard route: `POST /conversations/:id/custom_attributes` now returns Chatwoot `{ custom_attributes }` instead of the full local conversation serializer. The preceding transcript slice closed `POST /conversations/:id/transcript` response/status drift. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the next named Phase 2/3 or Phase 6 drift from fresh reference/smoke evidence. +- Worktree status at this implementation checkpoint: Phase 6 contacts active/list/search/filter pagination drift is closed for the reused dashboard CRM route family: contact index/search/active/filter now use Chatwoot's `RESULTS_PER_PAGE = 15`, and `GET /contacts/active` now returns the same `{ meta: { count, current_page }, payload: [...] }` contact serializer as `contacts/active.json.jbuilder` instead of the local `{ contacts, meta: { page, page_size } }` envelope. The selected-key contact `destroy_custom_attributes` route is covered against Chatwoot's `{ payload }` response with `contact_inboxes`. Conversation transcript and custom-attribute response drifts are already closed. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the next named Phase 2/3 or Phase 6 drift from fresh reference/smoke evidence. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 837` after adding Chatwoot auth reset/confirmation routes and `GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments`. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -143,6 +143,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(contacts): align active pagination payloads` | Advances Phase 6 dashboard CRM drift by aligning contact list/search/filter/active pagination with Chatwoot `ContactsController::RESULTS_PER_PAGE = 15` and `contacts/active.json.jbuilder`. The reused dashboard active-contacts route now returns `{ meta: { count, current_page }, payload }` using the contact serializer instead of the local `{ contacts, meta: { page, page_size } }` envelope, and selected-key `POST /contacts/:id/destroy_custom_attributes` is covered against Chatwoot's `{ payload }` response with `contact_inboxes`. | `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUD' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | Continue the next named Phase 2/3 or Phase 6 drift from reference/smoke evidence. | | `feat(conversations): align custom attribute response` | Advances Phase 6 dashboard conversation drift by aligning `POST /api/v1/accounts/:account_id/conversations/:conversation_id/custom_attributes` with Chatwoot `custom_attributes.json.jbuilder` and the reused conversation store action. The endpoint still persists the submitted custom attributes, but the response is now only `{ custom_attributes: ... }` and no longer leaks the full local conversation serializer, `id`, or local envelope fields. | `go test ./internal/handler/api/v1 -run 'TestConversationHandlerTestSuite/TestUpdateCustomAttributes' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | Continue the next named Phase 2/3 or Phase 6 drift from reference/smoke evidence. | | `feat(conversations): align transcript responses` | Advances Phase 6 dashboard conversation drift by aligning `POST /api/v1/accounts/:account_id/conversations/:conversation_id/transcript` with Chatwoot `ConversationsController#transcript` and the reused dashboard conversation API. Transcript success now returns empty `200 OK` instead of the local `{ success, data }` envelope; missing `email` returns Chatwoot `422 { error: "email param missing" }`; local email-format validation no longer rejects nonblank values that Chatwoot would pass through to the mailer. Fresh placeholder audit found only webhook handler nil-fallback `chatwootParityStub` routes, with no account/contact/conversation/message/inbox dashboard-critical stub owner left by this slice. | `go test ./internal/handler/api/v1 -run 'TestConversationHandlerTestSuite/TestTranscript' -count=1`; `go test ./internal/service -run ConversationService_SendTranscript -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | Continue the next named Phase 2/3 or Phase 6 drift from reference/smoke evidence. | | `feat(custom-fields): align chatwoot saved view payloads` | Advances P3.6 serializer/controller parity for saved views and settings attributes. Account `/custom_filters` now matches Chatwoot `CustomFiltersController`, `_custom_filter.json.jbuilder`, and dashboard `customViews.js`/store behavior: list returns a raw array, defaults to `filter_type=conversation`, scopes by current user, accepts raw frontend bodies plus nested `{ custom_filter }`, returns raw mutation payloads with no `account_id/user_id`, and uses `200 OK` for create/update plus `204` delete. Account `/custom_attribute_definitions` now matches `CustomAttributeDefinitionsController`, `_custom_attribute_definition.json.jbuilder`, dashboard `attributes.js`/store, and settings attribute enum constants: list/show/create/update return raw arrays/objects, create/update accept raw integer enum bodies plus wrappers, stored/serialized `attribute_model` values normalize to Chatwoot `conversation_attribute/contact_attribute/company_attribute`, and custom attribute value validation can find normalized and legacy definition rows. | `go test ./internal/handler/api/v1 -run 'CustomFilter\|CustomAttributeDefinition' -count=1`; `go test ./internal/service -run 'CustomFilter\|CustomAttributeDefinition' -count=1`; `go test ./internal/repository -run 'CustomFilter\|CustomAttributeDefinition' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | P3.6 custom filters/custom attribute definitions move to Review; continue Phase 6 placeholder audit from fresh reference/smoke evidence. | @@ -2067,6 +2068,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-06: Phase 6 contacts active/pagination checkpoint prepared as `feat(contacts): align active pagination payloads`; audited Chatwoot `ContactsController`, `contacts/index.json.jbuilder`, `contacts/search.json.jbuilder`, `contacts/filter.json.jbuilder`, `contacts/active.json.jbuilder`, `contacts/destroy_custom_attributes.json.jbuilder`, and dashboard `contacts.js`. Contact list/search/filter/active now use Chatwoot's fixed `RESULTS_PER_PAGE = 15`; active contacts now return `{ meta: { count, current_page }, payload }` through the contact serializer instead of the local `{ contacts, meta: { page, page_size } }` envelope; selected-key `destroy_custom_attributes` payload coverage confirms the Chatwoot `{ payload }` response and `contact_inboxes` inclusion. Focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-06: P3.6 label CRUD checkpoint prepared as `feat(labels): align chatwoot label payloads`; audited Chatwoot `LabelsController`, label Jbuilder views, `Label` model/schema, and dashboard labels API/store. Account `/labels` list now returns `{ payload }`, create/show/update return raw label payloads, create/update accept nested `{ label }` bodies plus legacy raw `name`, title values are normalized/lowercased, color defaults to `#1f93ff`, `description` and `show_on_sidebar` are persisted, account-scoped get/update/delete prevents cross-account access, and delete returns empty `200 OK` while removing conversation/contact label associations and legacy conversation label text. Focused label handler/service/repository tests, combined handler/service/router tests, migration tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-06: Documentation checkpoint prepared as `docs: refresh parity tracker baseline`; current baseline now points at `2ab7b58 feat(accounts): align account payloads`, the clean handoff state is recorded, and P3.6 label CRUD parity is fully landed as the next executable slice. Direct reference audit covered `LabelsController`, label Jbuilder views, `Label` model/schema, and dashboard labels API/store; current Go gaps are local `{ success, data }` envelopes, `name` instead of `title`, missing `description/show_on_sidebar/default color`, `201/204` status drift, and missing label title update/delete association side effects. Verification for this docs-only checkpoint: `git diff --check`; next implementation commit is `feat(labels): align chatwoot label payloads`. - 2026-06-05: Phase 2/3 account settings checkpoint prepared as `feat(accounts): align account payloads`; audited Chatwoot `AccountsController`, `_account.json.jbuilder`, account create/show/update views, dashboard `account.js`, account Vuex store, and account settings components. Account show/update/settings now return raw Chatwoot account objects with settings, features, custom attributes, cache keys, locale/name/domain/status/support fields instead of local success/data envelopes. Account creation now accepts Chatwoot `account_name` and returns `{ data: { account_id } }` for the add-account modal. `update_active_at` returns empty `200 OK`, and `cache_keys` returns `{ cache_keys: { label, inbox, team } }` with zero-epoch defaults. Focused account handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed; continue account users/invitations/permissions drift and Phase 6 placeholder audit next. diff --git a/internal/handler/api/v1/contact_handler.go b/internal/handler/api/v1/contact_handler.go index 66c7b5b4..7b669c23 100644 --- a/internal/handler/api/v1/contact_handler.go +++ b/internal/handler/api/v1/contact_handler.go @@ -25,6 +25,8 @@ type ContactHandler struct { conversationSvc *service.ConversationService } +const chatwootContactResultsPerPage = 15 + // NewContactHandler creates a new ContactHandler. func NewContactHandler(svc *service.ContactService, contactInboxSvc *service.ContactInboxService, mergeSvc *service.ContactMergeService, contactNoteSvc *service.ContactNoteService, conversationSvc ...*service.ConversationService) *ContactHandler { h := &ContactHandler{svc: svc, contactInboxSvc: contactInboxSvc, mergeSvc: mergeSvc, contactNoteSvc: contactNoteSvc} @@ -60,7 +62,7 @@ func (h *ContactHandler) List(c *gin.Context) { } page := getPage(c) - perPage := getPageSize(c) + perPage := chatwootContactResultsPerPage offset := (page - 1) * perPage sort := c.DefaultQuery("sort", "") @@ -106,7 +108,7 @@ func (h *ContactHandler) Search(c *gin.Context) { return } page := getPage(c) - perPage := getPageSize(c) + perPage := chatwootContactResultsPerPage offset := (page - 1) * perPage sort := c.DefaultQuery("sort", "") searchMode := search.ParseSearchMode(c.DefaultQuery("search_mode", "")) @@ -676,7 +678,7 @@ func (h *ContactHandler) DeleteContactInbox(c *gin.Context) { } // Active retrieves contacts with recent activity. -// GET /api/v1/accounts/:id/contacts/active?sort=name&page=1&page_size=25 +// GET /api/v1/accounts/:id/contacts/active?sort=name&page=1 // Reference: Chatwoot contacts#active func (h *ContactHandler) Active(c *gin.Context) { accountID := parseAccountIDParam(c) @@ -686,7 +688,7 @@ func (h *ContactHandler) Active(c *gin.Context) { } page := getPage(c) - perPage := getPageSize(c) + perPage := chatwootContactResultsPerPage offset := (page - 1) * perPage sort := c.DefaultQuery("sort", "") if !h.svc.Ready() { @@ -700,10 +702,7 @@ func (h *ContactHandler) Active(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{ - "contacts": contacts, - "meta": gin.H{"count": total, "page": page, "page_size": perPage}, - }) + c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil)) } // Export downloads contacts as CSV. @@ -932,14 +931,16 @@ func (h *ContactHandler) Filter(c *gin.Context) { return } - p := pagination.Parse(c) - contacts, total, svcErr := h.svc.Filter(c.Request.Context(), accountID, params, p.Offset, p.PerPage) + page := getPage(c) + perPage := chatwootContactResultsPerPage + offset := (page - 1) * perPage + contacts, total, svcErr := h.svc.Filter(c.Request.Context(), accountID, params, offset, perPage) if svcErr != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to filter contacts"}) return } - c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, total, p.Page, includeContactInboxes(c), nil)) + c.JSON(http.StatusOK, contactListResponse(c.Request.Context(), h.svc.DB(), contacts, total, page, includeContactInboxes(c), nil)) } // DestroyCustomAttributes removes all custom attributes from a contact. diff --git a/internal/handler/api/v1/contact_handler_crud_test.go b/internal/handler/api/v1/contact_handler_crud_test.go index a10596c7..9a0c1207 100644 --- a/internal/handler/api/v1/contact_handler_crud_test.go +++ b/internal/handler/api/v1/contact_handler_crud_test.go @@ -87,6 +87,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { s.router.Use(gin.Recovery(), s.mockAuthMiddleware()) s.router.GET("/api/v1/accounts/:id/contacts", s.handler.List) s.router.GET("/api/v1/accounts/:id/contacts/search", s.handler.Search) + s.router.GET("/api/v1/accounts/:id/contacts/active", s.handler.Active) s.router.POST("/api/v1/accounts/:id/contacts/export", s.handler.ExportRequest) s.router.GET("/api/v1/accounts/:id/contacts/export/:export_id/download", s.handler.DownloadExport) s.router.POST("/api/v1/accounts/:id/contacts/import", s.handler.Import) @@ -100,6 +101,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() { s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.UpdateLabels) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/contact_inboxes", s.handler.ListContactInboxes) + s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/destroy_custom_attributes", s.handler.DestroyCustomAttributes) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.ListNotes) s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/notes", s.handler.CreateNote) s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/notes/:note_id", s.handler.ShowNote) @@ -233,7 +235,7 @@ func (s *ContactHandlerCRUDTestSuite) TestList_InvalidAccountID() { func (s *ContactHandlerCRUDTestSuite) TestList_Pagination() { // Create additional contacts - for i := 0; i < 3; i++ { + for i := 0; i < 16; i++ { c := &model.Contact{ AccountID: s.account.ID, Name: fmt.Sprintf("Contact %d", i), @@ -243,7 +245,7 @@ func (s *ContactHandlerCRUDTestSuite) TestList_Pagination() { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", - fmt.Sprintf("/api/v1/accounts/%d/contacts?page=1&page_size=2", s.account.ID), nil) + fmt.Sprintf("/api/v1/accounts/%d/contacts?page=2&page_size=2", s.account.ID), nil) s.router.ServeHTTP(w, req) s.Equal(http.StatusOK, w.Code) @@ -254,11 +256,41 @@ func (s *ContactHandlerCRUDTestSuite) TestList_Pagination() { meta, ok := resp["meta"] s.True(ok) metaMap := meta.(map[string]interface{}) - s.Equal(float64(4), metaMap["count"]) // 1 original + 3 new + s.Equal(float64(17), metaMap["count"]) // 1 original + 16 new + s.Equal(float64(2), metaMap["current_page"]) payload := resp["payload"].([]interface{}) s.Len(payload, 2) } +func (s *ContactHandlerCRUDTestSuite) TestActive_ChatwootPayloadAndPageSize() { + now := time.Now().Unix() + for i := 0; i < 16; i++ { + c := &model.Contact{ + AccountID: s.account.ID, + Name: fmt.Sprintf("Active Contact %d", i), + Email: fmt.Sprintf("active-%d@example.com", i), + LastActivityAt: &now, + } + s.Require().NoError(s.db.Create(c).Error) + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", + fmt.Sprintf("/api/v1/accounts/%d/contacts/active?page=1&page_size=2", s.account.ID), nil) + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.NotContains(resp, "contacts") + meta := resp["meta"].(map[string]interface{}) + s.Equal(float64(16), meta["count"]) + s.Equal(float64(1), meta["current_page"]) + payload := resp["payload"].([]interface{}) + s.Len(payload, 15) +} + // =========================== // Search Contacts // =========================== @@ -777,6 +809,29 @@ func (s *ContactHandlerCRUDTestSuite) TestLabels_UpdateListAndFilter() { s.Equal(float64(s.contact.ID), payload[0].(map[string]interface{})["id"]) } +func (s *ContactHandlerCRUDTestSuite) TestDestroyCustomAttributes_SelectedKeysPayload() { + attrs := model.JSONMap{"tier": "gold", "plan": "pro", "vip": true} + s.contact.CustomAttributes = model.ToDatatypesJSON(&attrs) + s.Require().NoError(s.db.Save(s.contact).Error) + bodyBytes, _ := json.Marshal(map[string]interface{}{"custom_attributes": []string{"plan", "vip"}}) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", + fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/destroy_custom_attributes", s.account.ID, s.contact.ID), + bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + s.router.ServeHTTP(w, req) + + s.Equal(http.StatusOK, w.Code) + + var resp map[string]interface{} + s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + payload := resp["payload"].(map[string]interface{}) + customAttrs := payload["custom_attributes"].(map[string]interface{}) + s.Equal(map[string]interface{}{"tier": "gold"}, customAttrs) + s.Contains(payload, "contact_inboxes") +} + func (s *ContactHandlerCRUDTestSuite) TestDelete_InvalidAccountID() { w := httptest.NewRecorder() req, _ := http.NewRequest("DELETE",