diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 66ef9fde..f52b8591 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-05 after `f8263a3 feat(crm): align shared attachment payloads`, with this implementation checkpoint prepared as `feat(settings): align agents teams payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(settings): align agents teams payloads`. +- Current tracking checkpoint: 2026-06-05 after `dc09036 feat(settings): align agents teams payloads`, with this implementation checkpoint prepared as `feat(settings): align assignable agents payloads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(settings): align assignable agents payloads`. - Latest documentation-only checkpoint: `2923aae docs: land parity execution tracker`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics; P6.5 now aligns WhatsApp Cloud inbox health/register-webhook endpoints with Chatwoot raw payloads, Cloud-only guards, Graph health fields, default callback URL construction, and no-body register behavior; P6.5 also aligns `sync_templates` with Chatwoot's async low-priority template sync job and `{ message: "Template sync initiated successfully" }` response; Phase 2/3 auth now aligns Chatwoot Devise password reset/confirmation token columns, `POST/PUT /auth/password`, and `POST /auth/confirmation` payload/auth-header behavior; Phase 3/6 CRM attachments now aligns conversation shared-file payloads and adds Chatwoot contact shared-file attachments with meta totals, serialized attachment fields, sender, created_at, and display conversation IDs; Phase 2/3 settings now aligns Chatwoot agents, teams, and team-member payload envelopes, raw frontend request bodies, `allow_auto_assign`, custom-role assignment, and `:team_id` route params. Next active implementation slice is continued Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference evidence. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation; B11.3e aligns Captain stream DTOs/disabled SSE fallbacks and Copilot push-event payload shapes; B12.1 adds the reusable GoChat server/seed entrypoint plus a Meilisearch-first reused Chatwoot frontend smoke harness and report; B12.2a adds API smoke assertions for auth/profile, inbox, conversation/messages, contact/company, widget config/message, and public CSAT; B12.2b adds a zero-dependency Chrome DevTools browser smoke that loads the reused Chatwoot login and dashboard entrypoints through Vite and checks browser auth/dashboard API requests; B12.3a adds enterprise API smoke assertions for SLA reports/download, CSAT reports/download, automation/macros, audit/custom roles, capacity, Captain, and Copilot; B12.3b adds reused-frontend enterprise browser route navigation for SLA, CSAT, automation, macros, audit logs, custom roles, capacity, Captain, and Copilot request coverage; P5.1 adds the PostgreSQL-backed durable `background_jobs` model/migration plus WorkerPool enqueue, schedule, retry/backoff, dead-letter, idempotency, stale-lock recovery, and focused tests; P5.2 wires `channel.Dispatcher` and `dispatch.EventDispatcher` async paths into durable event jobs with worker replay tests; P5.3 queues Meilisearch write-side index/delete jobs for conversations, messages, contacts, companies, and articles while keeping search reads Meilisearch-first; P5.4 queues automation webhook and email transcript side effects as durable jobs while preserving fakeable delivery boundaries; P5.5 queues Chatwoot-style macro execute fan-out through durable `automation:macro_execution` jobs; P5.6 queues resolve-triggered CSAT survey sends and WhatsApp/Twilio CSAT template creation through durable jobs; P5.7 queues Chatwoot enterprise SLA account scans and applied-SLA evaluation jobs through the durable worker; P5.8 queues Chatwoot-style contact export artifact generation through durable `contact:export` jobs; P5.9 queues normalized provider inbound message persistence/dispatch through durable `webhook:incoming_message_persist` jobs; P5.10 queues Chatwoot `SendReplyJob`-style outbound message delivery through durable `message:send_reply` jobs and provider delivery-status/read-receipt updates through durable webhook status jobs; P5.11 queues Captain document sync, crawl/parser, schedule-sync, response-builder, embedding-update, Copilot response, and Captain conversation response-builder work through durable jobs; P5.12 queues scheduled item fan-out, one-off campaigns, snoozed conversation reopening, account auto-resolution, widget/public message status updates, and account conversation bulk actions through durable jobs; P5.13a replaces the live report, bot report, conversation summary, inbox-label matrix, first-response distribution, and outgoing-message placeholder responses with persisted conversation/message/reporting-event aggregations; P5.13b routes `GET /reports` to Chatwoot-style metric timeseries, adds lazy rollup freshness/idempotency, registers durable `reporting:rollup_day` jobs, and makes rollup replacement hard-delete soft-deleted rows before recompute; B9.3 verifies the current reference exposes no delayed automation action params, preserves scheduled-item coverage under P5.12, and completes remaining automation action side effects by queueing `send_email_to_team` plus applying enterprise `add_sla`; Phase 2/3 drift audit now aligns account notifications with Chatwoot `NotificationFinder` list envelopes, `includes` filters, unread counts, primary-actor `read_all`, raw action responses, and `destroy_all` read/all semantics; P6.5 now aligns WhatsApp Cloud inbox health/register-webhook endpoints with Chatwoot raw payloads, Cloud-only guards, Graph health fields, default callback URL construction, and no-body register behavior; P6.5 also aligns `sync_templates` with Chatwoot's async low-priority template sync job and `{ message: "Template sync initiated successfully" }` response; Phase 2/3 auth now aligns Chatwoot Devise password reset/confirmation token columns, `POST/PUT /auth/password`, and `POST /auth/confirmation` payload/auth-header behavior; Phase 3/6 CRM attachments now aligns conversation shared-file payloads and adds Chatwoot contact shared-file attachments with meta totals, serialized attachment fields, sender, created_at, and display conversation IDs; Phase 2/3 settings now aligns Chatwoot agents, teams, team members, and assignable-agent payload envelopes, raw frontend request bodies, `allow_auto_assign`, custom-role assignment, and `:team_id` route params. Next active implementation slice is continued Phase 6 placeholder audit, followed by further Phase 2/3 drift from smoke/reference 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`. @@ -87,7 +87,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers, Devise password reset/confirmation payloads, CRM shared attachment payloads, and settings agents/teams payloads now match Chatwoot's frontend contract; broader serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | +| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current critical set; notification list/action serializers, Devise password reset/confirmation payloads, CRM shared attachment payloads, settings agents/teams payloads, and assignable-agent payloads now match Chatwoot's frontend contract; broader serializer fixtures remain partial. | Expand route/serializer fixtures when smoke or reference inspection exposes drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; account/contact/conversation/message/inbox audit remains broad. | Continue fresh placeholder audit and assign every frontend-reachable stub to a tracked owner. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | | 3 | B12 optional live smoke | API/browser/enterprise smoke commands are checked in; live runs need PostgreSQL, Redis, Meilisearch, Vite, and Chrome. | Run full live smoke when environment is available and map failures to the board. | `docs/parity/frontend_smoke_report.md` records pass/fail and linked owners. | | 4 | B9.3 delayed automation actions | Current reference exposes no delayed automation action params; scheduled-item work is already P5.12; `send_email_to_team` is durable and `add_sla` mutates conversation/applied SLA state. | Keep automation drift closed if future reference/smoke exposes delayed params or unsupported action shapes. | Automation worker/action fixtures verify queued team email replay, retry visibility through worker jobs, and SLA action idempotency. | @@ -139,6 +139,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(settings): align assignable agents payloads` | Advances P3.2/P3.3 settings and inbox assignment parity by aligning `GET /api/v1/accounts/:account_id/assignable_agents` and `GET /api/v1/accounts/:account_id/inboxes/:inbox_id/assignable_agents` with Chatwoot `AssignableAgentsController#index`, `inboxes#assignable_agents`, and `_agent.json.jbuilder`. Assignable-agent endpoints now return `{ payload: [...] }` instead of the local `{ success, data }` envelope and serialize agent fields required by the reused conversation assignment UI: `account_id`, `availability_status`, `auto_offline`, `confirmed`, `provider`, `available_name`, `thumbnail`, role, and enterprise `custom_role_id`. AccountUser role, availability, auto-offline, and custom-role data are loaded from the account membership instead of the global user role. | `go test ./internal/handler/api/v1 -run AssignableAgent -count=1`; `go test ./internal/service -run AssignableAgent -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -count=1`; `go test ./...`; `git diff --check`. | P3.2/P3.3 remain Doing for deeper account users/invitations/permissions and inbox serializer drift; continue Phase 6 placeholder audit from fresh reference/smoke evidence. | | `feat(settings): align agents teams payloads` | Advances P3.2 account/users/teams parity by aligning settings agents, teams, and team-member endpoints with Chatwoot `agents_controller`, `teams_controller`, `team_members_controller`, and the reused frontend stores/API clients. Agent list/create/update/get now return raw Chatwoot agent objects/arrays with `availability_status`, `available_name`, `confirmed`, `thumbnail`, `auto_offline`, and `custom_role_id`; create/update accept the raw frontend body as well as wrapped params; bulk create and delete return Chatwoot `head :ok`. Team list/show/create/update now return raw team objects/arrays with `allow_auto_assign` and `is_member`, accept raw `allow_auto_assign`, preserve explicit false values despite DB defaults, and team-member list/create/update return agent serializer arrays. The handler also accepts router `:team_id` params used by the real account-scoped routes. | `go test ./internal/handler/api/v1 -run 'AgentHandler\|TeamHandler' -count=1`; `go test ./internal/service -run 'AgentService\|TeamService' -count=1`; `go test ./internal/repository ./internal/handler/api/v1 ./internal/router -count=1`; `go test ./...`; `git diff --check`. | P3.2 moves to Doing; continue accounts/users/invitations/permissions drift, then Phase 6 placeholder audit from fresh reference/smoke evidence. | | `feat(crm): align shared attachment payloads` | Advances Phase 3/6 CRM serializer parity by adding Chatwoot's `GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments` shared-file route and aligning conversation/contact attachment responses with `contacts/attachments#index`, `conversations#attachments`, and `_attachment.json.jbuilder`. Attachment lists now return `{ meta: { total_count }, payload }`, order newest first, serialize `data_url`, `thumb_url`, `file_size`, `file_type`, `extension`, dimensions, `created_at`, sender data, and contact attachment `conversation_id` as the Chatwoot display ID. | `go test ./internal/service -run 'MessageService\|ContactService' -count=1`; `go test ./internal/handler/api/v1 -run 'ContactHandlerCRUD\|ConversationHandler' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/router -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. | P3.5 remains Doing for broader contact/company drift; continue Phase 6 placeholder audit and CRM serializer fixture expansion from reference/smoke evidence. | | `feat(auth): align password reset flows` | Advances Phase 2/3 auth/session parity by replacing the password reset and confirmation placeholders with Chatwoot Devise-compatible token columns and frontend routes. `POST /auth/password` now returns Chatwoot's reset request message while storing a reset token digest; `PUT /auth/password` validates `reset_password_token`, updates and confirms the user password, clears reset/confirmation tokens, emits Devise auth headers, and returns `{ data: profile }`; `POST /auth/confirmation` confirms by `confirmation_token`, clears it, emits auth headers, and returns `{ data: profile }`. | `go test ./internal/service -run AuthService -count=1`; `go test ./internal/handler/api/v1 -run ChatwootAuth -count=1`; `go test ./...`; `git diff --check`. | Auth/session/profile remains Done for current reused frontend flow; future auth drift should come from B12 smoke or reference evidence. | @@ -1540,8 +1541,8 @@ Frontend-critical API groups to audit first: | ID | Area | Scope | Status | | --- | --- | --- | --- | | P3.1 | Auth/session/profile | Login, logout, current user, profile, availability, notification settings, Devise password reset, and confirmation. | Done | -| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; agents/teams/team-member settings payloads now have focused Chatwoot-style coverage. | -| P3.3 | Inboxes/channels | Inbox CRUD, assignable agents, avatars, channel config, business hours, widget config. | Done | +| P3.2 | Accounts/users/teams | Account settings, users, agents, teams, team members, invitations, roles, permissions. | Doing; agents/teams/team-member and assignable-agent settings payloads now have focused Chatwoot-style coverage. | +| P3.3 | Inboxes/channels | Inbox CRUD, assignable agents, avatars, channel config, business hours, widget config. | Review; assignable-agent payload drift closed, broader inbox serializer drift remains evidence-driven. | | P3.4 | Conversations/messages | List filters, status changes, assignment, labels, private notes, attachments, drafts, typing/read events. | Review | | P3.5 | Contacts/companies | CRUD, merge, labels, notes, custom attributes, import/export, conversations relation, and shared attachments. | Doing | | P3.6 | Labels/custom attributes/custom filters | Create/update/list behavior and exact response shapes. | Todo | @@ -2054,6 +2055,7 @@ Verification milestone gates: ## Progress Log +- 2026-06-05: Phase 2/3 settings/inbox assignment checkpoint prepared as `feat(settings): align assignable agents payloads`; audited Chatwoot `AssignableAgentsController#index`, inbox `assignable_agents` view, `_agent.json.jbuilder`, and reused dashboard assignable-agent API/store consumers. Assignable-agent endpoints now return Chatwoot `{ payload: [...] }` instead of local success/data envelopes, serialize the same agent fields used by conversation assignment UI, and source role, availability, auto-offline, and custom-role data from the account membership. Focused 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. - 2026-06-05: Phase 2/3 settings checkpoint prepared as `feat(settings): align agents teams payloads`; audited Chatwoot `AgentsController`, `TeamsController`, `TeamMembersController`, `_agent.json.jbuilder`, `_team.json.jbuilder`, and reused dashboard agents/teams API/store code. Agents now return raw Chatwoot arrays/objects instead of local `{ success, data, meta }` envelopes, accept raw frontend request bodies, expose `availability_status`, `available_name`, `confirmed`, `thumbnail`, `auto_offline`, and custom-role IDs, and match `head :ok` for bulk create/delete. Teams now return raw `allow_auto_assign`/`is_member` payloads, accept raw `allow_auto_assign`, preserve explicit false values, return team-member agent arrays, and parse account-scoped `:team_id` routes. Focused handler/service/repository/router tests, full `go test ./...`, and `git diff --check` passed; continue accounts/users/invitations drift and Phase 6 placeholder audit next. - 2026-06-05: Phase 3/6 CRM attachment checkpoint prepared as `feat(crm): align shared attachment payloads`; audited Chatwoot `Contacts::AttachmentsController#index`, `conversations#attachments`, `_attachment.json.jbuilder`, and the reused dashboard shared-files store/API. Added `GET /api/v1/accounts/:account_id/contacts/:contact_id/attachments`, aligned conversation attachment lists to `{ meta: { total_count }, payload }`, serialized shared-file fields through the Chatwoot attachment shape with sender and `created_at`, returned contact attachment `conversation_id` as display ID, regenerated `docs/parity/gochat_routes.txt` to `TOTAL: 837`, and reran route parity. Focused service/handler/router tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. - 2026-06-05: Phase 2/3 auth drift checkpoint prepared as `feat(auth): align password reset flows`; audited Chatwoot `DeviseOverrides::PasswordsController`, `DeviseOverrides::ConfirmationsController`, `Auth::ResendConfirmationsController`, and the reused v3 auth frontend. Added Chatwoot user token columns/migration, replaced password reset confirmation placeholders, aligned `POST /auth/password` reset request message, added `PUT /auth/password` reset-token password update with auth headers and `{ data: profile }`, and added `POST /auth/confirmation` confirmation-token sign-in behavior. Focused auth service/handler tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. diff --git a/internal/handler/api/v1/assignable_agent_handler.go b/internal/handler/api/v1/assignable_agent_handler.go index 3ed2c87c..95a6b6dd 100644 --- a/internal/handler/api/v1/assignable_agent_handler.go +++ b/internal/handler/api/v1/assignable_agent_handler.go @@ -80,5 +80,39 @@ func (h *AssignableAgentHandler) List(c *gin.Context) { return } - response.OK(c, agents) + c.JSON(http.StatusOK, gin.H{"payload": serializeAssignableAgents(accountID, agents)}) +} + +func serializeAssignableAgents(accountID uint, agents []service.AssignableAgentDTO) []map[string]any { + payload := make([]map[string]any, 0, len(agents)) + for i := range agents { + agent := agents[i] + availableName := agent.DisplayName + if availableName == "" { + availableName = agent.Name + } + provider := agent.Provider + if provider == "" { + provider = "email" + } + customRoleID := any(nil) + if agent.CustomRoleID != 0 { + customRoleID = agent.CustomRoleID + } + payload = append(payload, map[string]any{ + "id": agent.ID, + "account_id": accountID, + "availability_status": agent.AvailabilityStatus, + "auto_offline": agent.AutoOffline, + "confirmed": agent.Confirmed, + "email": agent.Email, + "provider": provider, + "available_name": availableName, + "name": agent.Name, + "role": agent.Role, + "thumbnail": agent.AvatarURL, + "custom_role_id": customRoleID, + }) + } + return payload } diff --git a/internal/handler/api/v1/assignable_agent_handler_test.go b/internal/handler/api/v1/assignable_agent_handler_test.go index f60998ca..a0fbb3cc 100644 --- a/internal/handler/api/v1/assignable_agent_handler_test.go +++ b/internal/handler/api/v1/assignable_agent_handler_test.go @@ -27,14 +27,14 @@ type AssignableAgentHandlerTestSuite struct { router *gin.Engine handler *AssignableAgentHandler - account *model.Account - inbox1 *model.Inbox - inbox2 *model.Inbox - inbox3 *model.Inbox - user1 *model.User // agent in inbox1 - user2 *model.User // agent in inbox1 + inbox2 - user3 *model.User // administrator of account - user4 *model.User // agent only in inbox2 + account *model.Account + inbox1 *model.Inbox + inbox2 *model.Inbox + inbox3 *model.Inbox + user1 *model.User // agent in inbox1 + user2 *model.User // agent in inbox1 + inbox2 + user3 *model.User // administrator of account + user4 *model.User // agent only in inbox2 } func (s *AssignableAgentHandlerTestSuite) SetupSuite() { @@ -168,6 +168,15 @@ func (s *AssignableAgentHandlerTestSuite) TearDownTest() { s.reseedData() } +func (s *AssignableAgentHandlerTestSuite) decodeAssignablePayload(w *httptest.ResponseRecorder) []interface{} { + var resp map[string]interface{} + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp)) + s.Require().NotContains(resp, "success") + payload, ok := resp["payload"].([]interface{}) + s.Require().True(ok, "expected Chatwoot payload array, got %v", resp) + return payload +} + func (s *AssignableAgentHandlerTestSuite) reseedData() { // Re-create the AccountUser + InboxMember associations s.Require().NoError(s.db.Create(&model.AccountUser{ @@ -211,13 +220,16 @@ func (s *AssignableAgentHandlerTestSuite) TestList_SingleInbox_ReturnsInboxMembe s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // Should include user1, user2 (inbox members) + user3 (administrator) = 3 s.Len(data, 3) + agent := data[0].(map[string]interface{}) + s.Contains(agent, "availability_status") + s.Contains(agent, "available_name") + s.Contains(agent, "auto_offline") + s.Contains(agent, "confirmed") + s.Contains(agent, "thumbnail") + s.Contains(agent, "custom_role_id") } func (s *AssignableAgentHandlerTestSuite) TestList_InboxWithOnlyOneMember() { @@ -229,11 +241,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_InboxWithOnlyOneMember() { s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // user2, user4 (inbox2 members) + user3 (administrator) = 3 s.Len(data, 3) } @@ -247,11 +255,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_InboxWithNoMembers_ReturnsOnl s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // Only administrator user3 s.Len(data, 1) } @@ -268,11 +272,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_MultipleInboxIDsQueryParam() s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // Intersection: user2. Admin: user3. Total: 2 s.Len(data, 2) } @@ -289,11 +289,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_MultipleInboxIDsQueryParams_N s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // Empty intersection + admin user3 s.Len(data, 1) } @@ -309,11 +305,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_QueryParamSameAsPrimaryInboxI s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // Same as single inbox1: user1, user2, user3 s.Len(data, 3) } @@ -332,11 +324,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_MultipleAdditionalInboxIDs() s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) s.Len(data, 1) } @@ -398,11 +386,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_InvalidQueryParamInboxIDs_Ign s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // Same as just inbox1: user1, user2, user3 s.Len(data, 3) } @@ -417,11 +401,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_NonExistentInboxID() { s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // No inbox members, only administrators: user3 s.Len(data, 1) } @@ -437,11 +417,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_NonExistentAccountID() { s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // Inbox members of inbox1: user1, user2. No admins for account 99999. s.Len(data, 2) } @@ -543,10 +519,8 @@ func (s *AssignableAgentHandlerTestSuite) TestList_NilService_PanicRecovered() { s.Equal(http.StatusUnprocessableEntity, w.Code) } - - func (s *AssignableAgentHandlerTestSuite) TestList_ResponseStructure() { - // Verify the response structure matches: {success: true, data: [...]} + // Verify the response structure matches Chatwoot: {payload: [...]} w := httptest.NewRecorder() req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/assignable_agents", s.account.ID, s.inbox1.ID), nil) @@ -557,10 +531,9 @@ func (s *AssignableAgentHandlerTestSuite) TestList_ResponseStructure() { var resp map[string]interface{} s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - // Must have "success" key set to true - s.True(resp["success"].(bool)) - // Must have "data" key - s.NotNil(resp["data"]) + s.NotContains(resp, "success") + s.NotContains(resp, "data") + s.NotNil(resp["payload"]) // No "meta" key for non-paginated response s.Nil(resp["meta"]) // No "error" key for success response @@ -581,11 +554,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_AdministratorDeduplication() s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // user1, user2 (inbox members), user3 (admin AND inbox member) - deduplicated // Should be 3, not 4 (user3 counted once) s.Len(data, 3) @@ -609,11 +578,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_AccountWithNoAdministrators() s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) // Only inbox member user1 (no administrators added) s.Len(data, 1) } @@ -627,9 +592,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_LargeAccountID() { s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) + s.decodeAssignablePayload(w) } func (s *AssignableAgentHandlerTestSuite) TestList_NegativeAccountID_ParseError() { @@ -705,11 +668,7 @@ func (s *AssignableAgentHandlerTestSuite) TestList_TwoInboxesWithPartialIntersec s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) s.Len(data, 2) } @@ -728,15 +687,11 @@ func (s *AssignableAgentHandlerTestSuite) TestList_ThreeInboxesIntersectionWithT s.Equal(http.StatusOK, w.Code) - var resp map[string]interface{} - s.NoError(json.Unmarshal(w.Body.Bytes(), &resp)) - s.True(resp["success"].(bool)) - - data := resp["data"].([]interface{}) + data := s.decodeAssignablePayload(w) s.Len(data, 2) } // Run the test suite func TestAssignableAgentHandlerSuite(t *testing.T) { suite.Run(t, new(AssignableAgentHandlerTestSuite)) -} \ No newline at end of file +} diff --git a/internal/service/assignable_agent_service.go b/internal/service/assignable_agent_service.go index 899bc705..104acbdf 100644 --- a/internal/service/assignable_agent_service.go +++ b/internal/service/assignable_agent_service.go @@ -14,35 +14,41 @@ import ( // 逻辑: 对每个inbox在inbox_ids[]中的成员取交集,加上account管理员,去重。 // 增强功能: 计算每个agent的workload (open conversations数量),按workload升序排序。 type AssignableAgentService struct { - inboxMemberRepo *repository.InboxMemberRepo - userRepo *repository.UserRepo - accountRepo *repository.AccountRepo - conversationRepo *repository.ConversationRepo + inboxMemberRepo *repository.InboxMemberRepo + userRepo *repository.UserRepo + accountRepo *repository.AccountRepo + conversationRepo *repository.ConversationRepo } // NewAssignableAgentService 创建新的AssignableAgentService。 func NewAssignableAgentService(inboxMemberRepo *repository.InboxMemberRepo, userRepo *repository.UserRepo, accountRepo *repository.AccountRepo, conversationRepo *repository.ConversationRepo) *AssignableAgentService { return &AssignableAgentService{ - inboxMemberRepo: inboxMemberRepo, - userRepo: userRepo, - accountRepo: accountRepo, - conversationRepo: conversationRepo, + inboxMemberRepo: inboxMemberRepo, + userRepo: userRepo, + accountRepo: accountRepo, + conversationRepo: conversationRepo, } } // AssignableAgentDTO 是可分配agent的API响应结构,包含workload信息。 // Reference: Chatwoot assignable_agents API — 返回agent列表及其当前workload type AssignableAgentDTO struct { - ID uint `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Role string `json:"role"` - Active bool `json:"active"` - Available bool `json:"available"` - AvatarURL string `json:"avatar_url"` - AvailabilityStatus string `json:"availability_status"` // online, offline, busy - Workload int64 `json:"workload"` // 当前open conversations数量 - IsAdministrator bool `json:"is_administrator"` // 是否为account管理员 + ID uint `json:"id"` + AccountID uint `json:"account_id"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + Active bool `json:"active"` + Available bool `json:"available"` + Provider string `json:"provider"` + DisplayName string `json:"available_name"` + AvatarURL string `json:"avatar_url"` + AutoOffline bool `json:"auto_offline"` + Confirmed bool `json:"confirmed"` + CustomRoleID uint `json:"custom_role_id,omitempty"` + AvailabilityStatus string `json:"availability_status"` // online, offline, busy + Workload int64 `json:"workload"` // 当前open conversations数量 + IsAdministrator bool `json:"is_administrator"` // 是否为account管理员 } // FindAssignableAgents 返回可以被分配到指定inbox对话中的agents。 @@ -131,36 +137,48 @@ func (s *AssignableAgentService) GetAssignableAgents(ctx context.Context, accoun } } - // 获取account的管理员IDs用于标记is_administrator - adminIDs, _ := s.findAdministratorIDs(ctx, accountID) - adminSet := make(map[uint]bool, len(adminIDs)) - for _, id := range adminIDs { - adminSet[id] = true - } - - // 获取每个agent的availability status (从account_user表中获取) - availabilityMap := make(map[uint]string) + // 获取每个agent的AccountUser字段,用于复用Chatwoot _agent serializer shape。 + accountUserMap := make(map[uint]model.AccountUser) accountUsers, _, auErr := s.accountRepo.FindAgentsByAccount(ctx, accountID, 0, 1000) if auErr == nil { for _, au := range accountUsers { - availabilityMap[au.UserID] = au.Availability + accountUserMap[au.UserID] = au } } // 构建DTO列表 dtos := make([]AssignableAgentDTO, len(users)) for i, u := range users { + accountUser := accountUserMap[u.ID] + role := accountUser.Role + if role == "" { + role = u.Role + } + availabilityStatus := accountUser.Availability + if availabilityStatus == "" { + if u.Available { + availabilityStatus = "online" + } else { + availabilityStatus = "offline" + } + } dtos[i] = AssignableAgentDTO{ ID: u.ID, + AccountID: accountID, Name: u.Name, Email: u.Email, - Role: u.Role, + Role: role, Active: u.Active, Available: u.Available, + Provider: u.Provider, + DisplayName: u.DisplayName, AvatarURL: u.AvatarURL, - AvailabilityStatus: availabilityMap[u.ID], + AutoOffline: accountUser.AutoOffline, + Confirmed: u.ConfirmedAt != nil, + CustomRoleID: accountUser.CustomRoleID, + AvailabilityStatus: availabilityStatus, Workload: workloadMap[u.ID], - IsAdministrator: adminSet[u.ID], + IsAdministrator: role == "administrator", } } @@ -232,4 +250,4 @@ func union(a, b []uint) []uint { } } return result -} \ No newline at end of file +}