diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 7492e7d5..66ef9fde 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 `ad5d864 feat(auth): align password reset flows`, with this implementation checkpoint prepared as `feat(crm): align shared attachment payloads`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(crm): align shared attachment payloads`. +- 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`. - 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. 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, 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. - `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, and CRM shared attachment 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, 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. | | 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 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. | | `feat(inboxes): queue template sync jobs` | Advances P6.5 inbox API parity by aligning `POST /inboxes/:id/sync_templates` with `Api::V1::Accounts::Concerns::WhatsappHealthManagement#sync_templates`. The endpoint no longer blocks on live provider template fetches or returns a local `templates` envelope; it validates WhatsApp inboxes, queues durable low-priority `inbox:sync_templates` jobs, replays the existing WhatsApp template fetch/update path in the worker, and returns Chatwoot `{ message: "Template sync initiated successfully" }`. | `go test ./internal/service -run 'InboxService.*SyncTemplates\|InboxService.*Health\|InboxService.*RegisterWebhook' -count=1`; `go test ./internal/handler/api/v1 -run InboxSyncTemplates -count=1`; `go test ./internal/app -count=1`; `go test ./...`; `git diff --check`. | P6.5 remains Doing for broader inbox/channel settings parity; continue Phase 6 placeholder audit across account/contact/conversation/message/inbox surfaces. | @@ -1539,7 +1540,7 @@ 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, invitations, roles, permissions. | Todo | +| 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.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 | @@ -2053,6 +2054,7 @@ Verification milestone gates: ## Progress Log +- 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. - 2026-06-05: P6.5 inbox sync-template checkpoint prepared as `feat(inboxes): queue template sync jobs`; audited Chatwoot `WhatsappHealthManagement#sync_templates`, `Channels::Whatsapp::TemplatesSyncJob`, and the reused frontend inbox API/store action. `POST /inboxes/:id/sync_templates` now validates WhatsApp inboxes, enqueues durable low-priority `inbox:sync_templates` jobs instead of synchronously fetching provider templates, replays the existing WhatsApp template fetch/update path in the worker, and returns Chatwoot `{ message: "Template sync initiated successfully" }` without a local `templates` envelope. Focused inbox service/handler/app tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder audit next. diff --git a/internal/handler/api/v1/agent_handler.go b/internal/handler/api/v1/agent_handler.go index bd6d0bec..1b54c314 100644 --- a/internal/handler/api/v1/agent_handler.go +++ b/internal/handler/api/v1/agent_handler.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" @@ -44,7 +45,8 @@ func (h *AgentHandler) List(c *gin.Context) { return } - response.OKWithMeta(c, agents, pg.Page, pg.PerPage, total) + _ = total + c.JSON(http.StatusOK, serializeAgentDetails(agents, accountID)) } // Get returns a single agent by ID. @@ -69,7 +71,7 @@ func (h *AgentHandler) Get(c *gin.Context) { return } - response.OK(c, agent) + c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } // Create adds an agent to the account. @@ -88,15 +90,11 @@ func (h *AgentHandler) Create(c *gin.Context) { return } - // Chatwoot: params.require(:agent) → request body must be {"agent": {...}} - var wrapper struct { - Agent service.CreateAgentRequest `json:"agent"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.CreateAgentRequest + if err := bindJSONWrappedOrRaw(c, "agent", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - req := wrapper.Agent // Chatwoot: validate_limit → can_add_agent? — returns 402 if limit exceeded canAdd, err := h.svc.CanAddAgent(c.Request.Context(), accountID) @@ -121,7 +119,7 @@ func (h *AgentHandler) Create(c *gin.Context) { return } - response.Created(c, agent) + c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } // Update modifies agent details (name on User, role/availability on AccountUser). @@ -140,15 +138,11 @@ func (h *AgentHandler) Update(c *gin.Context) { return } - // Chatwoot: params.require(:agent) → request body must be {"agent": {...}} - var wrapper struct { - Agent service.UpdateAgentRequest `json:"agent"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.UpdateAgentRequest + if err := bindJSONWrappedOrRaw(c, "agent", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - req := wrapper.Agent agent, svcErr := h.svc.Update(c.Request.Context(), uint(id), accountID, req) if svcErr != nil { @@ -157,7 +151,7 @@ func (h *AgentHandler) Update(c *gin.Context) { return } - response.OK(c, agent) + c.JSON(http.StatusOK, serializeAgentDetail(agent, accountID)) } // Delete removes an agent from the account. @@ -183,7 +177,7 @@ func (h *AgentHandler) Delete(c *gin.Context) { return } - response.OK(c, gin.H{"message": "agent removed"}) + c.Status(http.StatusOK) } // BulkCreate adds multiple agents to the account by email. @@ -220,12 +214,63 @@ func (h *AgentHandler) BulkCreate(c *gin.Context) { return } - agents, svcErr := h.svc.BulkCreate(c.Request.Context(), accountID, userID, req) + _, svcErr := h.svc.BulkCreate(c.Request.Context(), accountID, userID, req) if svcErr != nil { applogger.L().Errorf("BulkCreate agents for account %d: %v", accountID, svcErr) handleServiceError(c, svcErr) return } - response.OK(c, agents) -} \ No newline at end of file + c.Status(http.StatusOK) +} + +func serializeAgentDetails(agents []repository.AgentDetail, accountID uint) []map[string]any { + payload := make([]map[string]any, 0, len(agents)) + for i := range agents { + payload = append(payload, serializeAgentDetail(&agents[i], accountID)) + } + return payload +} + +func serializeAgentDetail(agent *repository.AgentDetail, accountID uint) map[string]any { + if agent == nil { + return map[string]any{} + } + payload := serializeAgentUser(&agent.User, accountID, agent.Role, agent.Availability, agent.AutoOffline, agent.CustomRoleID) + if agent.InvitedBy != 0 { + payload["invited_by"] = agent.InvitedBy + } + if agent.AccountUserID != 0 { + payload["account_user_id"] = agent.AccountUserID + } + return payload +} + +func serializeAgentUser(user *model.User, accountID uint, role string, availability string, autoOffline bool, customRoleID uint) map[string]any { + if user == nil { + return map[string]any{} + } + availabilityStatus := nonEmpty(availability, availabilityStatus(user.Available)) + payload := map[string]any{ + "id": user.ID, + "account_id": accountID, + "availability_status": availabilityStatus, + "auto_offline": autoOffline, + "confirmed": user.ConfirmedAt != nil, + "email": user.Email, + "provider": nonEmpty(user.Provider, "email"), + "available_name": nonEmpty(user.DisplayName, user.Name), + "name": user.Name, + "role": nonEmpty(role, user.Role), + "thumbnail": user.AvatarURL, + } + if attrs := jsonObject(user.CustomAttributes); len(attrs) > 0 { + payload["custom_attributes"] = attrs + } + if customRoleID != 0 { + payload["custom_role_id"] = customRoleID + } else if user.CustomRoleID != nil && *user.CustomRoleID != 0 { + payload["custom_role_id"] = *user.CustomRoleID + } + return payload +} diff --git a/internal/handler/api/v1/agent_handler_test.go b/internal/handler/api/v1/agent_handler_test.go index 3dd5858d..29c99e5b 100644 --- a/internal/handler/api/v1/agent_handler_test.go +++ b/internal/handler/api/v1/agent_handler_test.go @@ -7,7 +7,6 @@ import ( "net/http" "net/http/httptest" "regexp" - "strings" "testing" "github.com/gin-gonic/gin" @@ -78,15 +77,7 @@ func (s *AgentHandlerTestSuite) makeRequest(method, path string, body interface{ var bodyBytes []byte if body != nil { - // Chatwoot params.require(:agent) → wrap body under "agent" key for POST/PUT/PATCH - // Exception: bulk_create does NOT use params.require - needsWrap := (method == "POST" || method == "PUT" || method == "PATCH") && !strings.Contains(path, "bulk_create") - if needsWrap { - wrapped := map[string]interface{}{"agent": body} - bodyBytes, _ = json.Marshal(wrapped) - } else { - bodyBytes, _ = json.Marshal(body) - } + bodyBytes, _ = json.Marshal(body) } c.Request = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) if body != nil { @@ -117,29 +108,30 @@ func (s *AgentHandlerTestSuite) TestListEmpty() { s.handler.List(c) assert.Equal(s.T(), http.StatusOK, w.Code) - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - data := resp["data"].([]interface{}) + var data []interface{} + assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &data)) assert.Equal(s.T(), 0, len(data)) } func (s *AgentHandlerTestSuite) TestCreateAgent() { + customRoleID := uint(7) req := service.CreateAgentRequest{ - Email: "agent1@test.com", - Name: "Agent One", - Role: "agent", + Email: "agent1@test.com", + Name: "Agent One", + Role: "agent", + CustomRoleID: &customRoleID, } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) - assert.Equal(s.T(), http.StatusCreated, w.Code) - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - data := resp["data"].(map[string]interface{}) + assert.Equal(s.T(), http.StatusOK, w.Code) + var data map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &data) assert.Equal(s.T(), "agent1@test.com", data["email"]) assert.Equal(s.T(), "Agent One", data["name"]) + assert.Equal(s.T(), "offline", data["availability_status"]) + assert.Equal(s.T(), float64(7), data["custom_role_id"]) + assert.Contains(s.T(), data, "confirmed") } func (s *AgentHandlerTestSuite) TestCreateAgentDuplicate() { @@ -151,7 +143,7 @@ func (s *AgentHandlerTestSuite) TestCreateAgentDuplicate() { } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) - assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) // Try creating again — should conflict w2, c2 := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) @@ -178,11 +170,10 @@ func (s *AgentHandlerTestSuite) TestGetAgent() { } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) - assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) - var createResp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &createResp) - data := createResp["data"].(map[string]interface{}) + var data map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &data) agentID := uint(data["id"].(float64)) // Get the agent @@ -191,10 +182,8 @@ func (s *AgentHandlerTestSuite) TestGetAgent() { s.handler.Get(c2) assert.Equal(s.T(), http.StatusOK, w2.Code) - var getResp map[string]interface{} - json.Unmarshal(w2.Body.Bytes(), &getResp) - assert.True(s.T(), getResp["success"].(bool)) - getData := getResp["data"].(map[string]interface{}) + var getData map[string]interface{} + json.Unmarshal(w2.Body.Bytes(), &getData) assert.Equal(s.T(), "agent3@test.com", getData["email"]) } @@ -213,17 +202,16 @@ func (s *AgentHandlerTestSuite) TestUpdateAgent() { } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) - assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) - var createResp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &createResp) - data := createResp["data"].(map[string]interface{}) + var data map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &data) agentID := uint(data["id"].(float64)) // Update the agent updateReq := service.UpdateAgentRequest{ - Name: "Updated Name", - Role: "administrator", + Name: "Updated Name", + Role: "administrator", Availability: "online", } w2, c2 := s.makeRequest("PUT", fmt.Sprintf("/api/v1/accounts/1/agents/%d", agentID), updateReq, s.account.ID, s.user.ID) @@ -231,13 +219,11 @@ func (s *AgentHandlerTestSuite) TestUpdateAgent() { s.handler.Update(c2) assert.Equal(s.T(), http.StatusOK, w2.Code) - var updateResp map[string]interface{} - json.Unmarshal(w2.Body.Bytes(), &updateResp) - assert.True(s.T(), updateResp["success"].(bool)) - updateData := updateResp["data"].(map[string]interface{}) + var updateData map[string]interface{} + json.Unmarshal(w2.Body.Bytes(), &updateData) assert.Equal(s.T(), "Updated Name", updateData["name"]) assert.Equal(s.T(), "administrator", updateData["role"]) - assert.Equal(s.T(), "online", updateData["availability"]) + assert.Equal(s.T(), "online", updateData["availability_status"]) } func (s *AgentHandlerTestSuite) TestDeleteAgent() { @@ -249,11 +235,10 @@ func (s *AgentHandlerTestSuite) TestDeleteAgent() { } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) - assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) - var createResp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &createResp) - data := createResp["data"].(map[string]interface{}) + var data map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &data) agentID := uint(data["id"].(float64)) // Delete the agent @@ -278,18 +263,7 @@ func (s *AgentHandlerTestSuite) TestBulkCreate() { s.handler.BulkCreate(c) assert.Equal(s.T(), http.StatusOK, w.Code) - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - assert.True(s.T(), resp["success"].(bool)) - data := resp["data"].([]interface{}) - assert.Equal(s.T(), 3, len(data)) - - // Verify emails - for i, agent := range data { - agentMap := agent.(map[string]interface{}) - expectedEmail := fmt.Sprintf("bulk%d@test.com", i+1) - assert.Equal(s.T(), expectedEmail, agentMap["email"]) - } + assert.Empty(s.T(), w.Body.String()) } func (s *AgentHandlerTestSuite) TestBulkCreateValidation() { @@ -310,7 +284,7 @@ func (s *AgentHandlerTestSuite) TestBulkCreateSkipsDuplicates() { } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", preReq, s.account.ID, s.user.ID) s.handler.Create(c) - assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) // Bulk create with a duplicate + a new one req := service.BulkCreateAgentRequest{ @@ -320,13 +294,7 @@ func (s *AgentHandlerTestSuite) TestBulkCreateSkipsDuplicates() { s.handler.BulkCreate(c2) assert.Equal(s.T(), http.StatusOK, w2.Code) - var resp map[string]interface{} - json.Unmarshal(w2.Body.Bytes(), &resp) - data := resp["data"].([]interface{}) - // Only the new one should be in results (existing skipped silently) - assert.Equal(s.T(), 1, len(data)) - newAgent := data[0].(map[string]interface{}) - assert.Equal(s.T(), "new@test.com", newAgent["email"]) + assert.Empty(s.T(), w2.Body.String()) } func (s *AgentHandlerTestSuite) TestListAfterCreate() { @@ -339,7 +307,7 @@ func (s *AgentHandlerTestSuite) TestListAfterCreate() { } w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID) s.handler.Create(c) - assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) } // List agents @@ -347,9 +315,8 @@ func (s *AgentHandlerTestSuite) TestListAfterCreate() { s.handler.List(c) assert.Equal(s.T(), http.StatusOK, w.Code) - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - data := resp["data"].([]interface{}) + var data []interface{} + json.Unmarshal(w.Body.Bytes(), &data) assert.Equal(s.T(), 2, len(data)) } @@ -360,4 +327,4 @@ func (s *AgentHandlerTestSuite) TestListNoAccountID() { // No account_id set s.handler.List(c) assert.Equal(s.T(), http.StatusUnauthorized, w.Code) -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/helpers.go b/internal/handler/api/v1/helpers.go index 659db746..621ed936 100644 --- a/internal/handler/api/v1/helpers.go +++ b/internal/handler/api/v1/helpers.go @@ -1,11 +1,34 @@ package v1 import ( + "bytes" + "encoding/json" + "fmt" + "io" "strconv" "github.com/gin-gonic/gin" ) +func bindJSONWrappedOrRaw(c *gin.Context, wrapperKey string, target any) error { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + return err + } + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + if len(bytes.TrimSpace(body)) == 0 { + return fmt.Errorf("empty request body") + } + + var wrapper map[string]json.RawMessage + if err := json.Unmarshal(body, &wrapper); err == nil { + if raw, ok := wrapper[wrapperKey]; ok && len(raw) > 0 && string(raw) != "null" { + return json.Unmarshal(raw, target) + } + } + return json.Unmarshal(body, target) +} + // parseUintParam extracts a uint path parameter from the Gin context. func parseUintParam(c *gin.Context, param string) (uint, error) { val := c.Param(param) diff --git a/internal/handler/api/v1/team_handler.go b/internal/handler/api/v1/team_handler.go index 719a3635..0d0b32a3 100644 --- a/internal/handler/api/v1/team_handler.go +++ b/internal/handler/api/v1/team_handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/pagination" @@ -40,7 +41,8 @@ func (h *TeamHandler) List(c *gin.Context) { return } - response.OKWithMeta(c, teams, pg.Page, pg.PerPage, total) + _ = total + c.JSON(http.StatusOK, serializeTeams(c, h.svc, teams)) } // Get returns a single team by ID. @@ -52,7 +54,7 @@ func (h *TeamHandler) Get(c *gin.Context) { return } - id, err := strconv.ParseUint(c.Param("id"), 10, 32) + id, err := parseTeamIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team ID") return @@ -65,7 +67,7 @@ func (h *TeamHandler) Get(c *gin.Context) { return } - response.OK(c, team) + c.JSON(http.StatusOK, serializeTeamForAccount(c, h.svc, team)) } // Create creates a new team within an account. @@ -77,15 +79,11 @@ func (h *TeamHandler) Create(c *gin.Context) { return } - // Chatwoot: params.require(:team) → request body must be {"team": {...}} - var wrapper struct { - Team service.CreateTeamRequest `json:"team"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.CreateTeamRequest + if err := bindJSONWrappedOrRaw(c, "team", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } - req := wrapper.Team team, svcErr := h.svc.Create(c.Request.Context(), accountID, req) if svcErr != nil { @@ -94,7 +92,7 @@ func (h *TeamHandler) Create(c *gin.Context) { return } - response.Created(c, team) + c.JSON(http.StatusOK, serializeTeamForAccount(c, h.svc, team)) } // Update updates an existing team. @@ -106,21 +104,17 @@ func (h *TeamHandler) Update(c *gin.Context) { return } - id, err := strconv.ParseUint(c.Param("id"), 10, 32) + id, err := parseTeamIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team ID") return } - // Chatwoot: params.require(:team) → request body must be {"team": {...}} - var wrapper struct { - Team service.UpdateTeamRequest `json:"team"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + var req service.UpdateTeamRequest + if err := bindJSONWrappedOrRaw(c, "team", &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error()) return } - req := wrapper.Team team, svcErr := h.svc.Update(c.Request.Context(), uint(id), accountID, req) if svcErr != nil { @@ -129,7 +123,7 @@ func (h *TeamHandler) Update(c *gin.Context) { return } - response.OK(c, team) + c.JSON(http.StatusOK, serializeTeamForAccount(c, h.svc, team)) } // Delete soft-deletes a team. @@ -141,7 +135,7 @@ func (h *TeamHandler) Delete(c *gin.Context) { return } - id, err := strconv.ParseUint(c.Param("id"), 10, 32) + id, err := parseTeamIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team ID") return @@ -153,7 +147,7 @@ func (h *TeamHandler) Delete(c *gin.Context) { return } - response.NoContent(c) + c.Status(http.StatusOK) } // AddMembers adds users to a team. @@ -165,7 +159,7 @@ func (h *TeamHandler) AddMembers(c *gin.Context) { return } - id, err := strconv.ParseUint(c.Param("id"), 10, 32) + id, err := parseTeamIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team ID") return @@ -177,14 +171,19 @@ func (h *TeamHandler) AddMembers(c *gin.Context) { return } - members, svcErr := h.svc.AddMembers(c.Request.Context(), uint(id), accountID, req.UserIDs) + _, svcErr := h.svc.AddMembers(c.Request.Context(), uint(id), accountID, req.UserIDs) if svcErr != nil { applogger.L().Errorf("Add members to team %d for account %d: %v", id, accountID, svcErr) handleServiceError(c, svcErr) return } + members, svcErr := h.svc.ListMembers(c.Request.Context(), uint(id), accountID) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } - response.OK(c, members) + c.JSON(http.StatusOK, serializeTeamMemberAgents(accountID, members)) } // RemoveMembers removes a user from a team. @@ -196,7 +195,7 @@ func (h *TeamHandler) RemoveMembers(c *gin.Context) { return } - id, err := strconv.ParseUint(c.Param("id"), 10, 32) + id, err := parseTeamIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team ID") return @@ -214,7 +213,7 @@ func (h *TeamHandler) RemoveMembers(c *gin.Context) { return } - response.NoContent(c) + c.Status(http.StatusOK) } // ListMembers retrieves all members of a team. @@ -226,7 +225,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) { return } - id, err := strconv.ParseUint(c.Param("id"), 10, 32) + id, err := parseTeamIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team ID") return @@ -239,7 +238,7 @@ func (h *TeamHandler) ListMembers(c *gin.Context) { return } - response.OK(c, members) + c.JSON(http.StatusOK, serializeTeamMemberAgents(accountID, members)) } // UpdateMembers adds/removes members to match the provided user_ids list. @@ -252,7 +251,7 @@ func (h *TeamHandler) UpdateMembers(c *gin.Context) { return } - teamID, err := strconv.ParseUint(c.Param("team_id"), 10, 32) + teamID, err := parseTeamIDParam(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team ID") return @@ -271,5 +270,51 @@ func (h *TeamHandler) UpdateMembers(c *gin.Context) { return } - response.OK(c, members) + c.JSON(http.StatusOK, serializeTeamMemberAgents(accountID, members)) +} + +func parseTeamIDParam(c *gin.Context) (uint64, error) { + value := c.Param("team_id") + if value == "" { + value = c.Param("id") + } + return strconv.ParseUint(value, 10, 32) +} + +func serializeTeams(c *gin.Context, svc *service.TeamService, teams []model.Team) []map[string]any { + payload := make([]map[string]any, 0, len(teams)) + for i := range teams { + payload = append(payload, serializeTeamForAccount(c, svc, &teams[i])) + } + return payload +} + +func serializeTeamForAccount(c *gin.Context, svc *service.TeamService, team *model.Team) map[string]any { + if team == nil { + return map[string]any{} + } + payload := map[string]any{ + "id": team.ID, + "name": team.Name, + "description": team.Description, + "allow_auto_assign": team.AllowAutoAssignment, + "account_id": team.AccountID, + "is_member": false, + } + userID := getUserID(c) + if userID != 0 && svc != nil && svc.DB() != nil { + var count int64 + svc.DB().WithContext(c.Request.Context()).Model(&model.TeamMember{}).Where("team_id = ? AND user_id = ?", team.ID, userID).Count(&count) + payload["is_member"] = count > 0 + } + return payload +} + +func serializeTeamMemberAgents(accountID uint, members []model.TeamMember) []map[string]any { + payload := make([]map[string]any, 0, len(members)) + for i := range members { + member := members[i] + payload = append(payload, serializeAgentUser(&member.User, accountID, "", member.AvailabilityStatus, false, 0)) + } + return payload } diff --git a/internal/handler/api/v1/team_handler_test.go b/internal/handler/api/v1/team_handler_test.go index 158ffcb6..7587501f 100644 --- a/internal/handler/api/v1/team_handler_test.go +++ b/internal/handler/api/v1/team_handler_test.go @@ -2,6 +2,7 @@ package v1 import ( "bytes" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -74,6 +75,8 @@ func (s *TeamHandlerTestSuite) TestList_Success() { r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) + var payload []map[string]any + assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &payload)) } func (s *TeamHandlerTestSuite) TestCreate_Success() { @@ -84,12 +87,16 @@ func (s *TeamHandlerTestSuite) TestCreate_Success() { }) w := httptest.NewRecorder() - body := `{"team":{"name":"test-team","description":"a test team"}}` + body := `{"name":"test-team","description":"a test team","allow_auto_assign":false}` req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/teams", s.account.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusCreated, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), "test-team", payload["name"]) + assert.Equal(s.T(), false, payload["allow_auto_assign"]) } func (s *TeamHandlerTestSuite) TestGet_Success() { @@ -106,6 +113,27 @@ func (s *TeamHandlerTestSuite) TestGet_Success() { req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/teams/%d", s.account.ID, team.ID), nil) r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), "get-test-team", payload["name"]) + assert.Contains(s.T(), payload, "is_member") +} + +func (s *TeamHandlerTestSuite) TestGet_TeamIDParamSuccess() { + team := &model.Team{AccountID: s.account.ID, Name: "team-id-param"} + s.Require().NoError(s.db.Create(team).Error) + + r := gin.New() + r.GET("/api/v1/accounts/:account_id/teams/:team_id", func(c *gin.Context) { + s.authMiddleware(c) + s.handler.Get(c) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/accounts/%d/teams/%d", s.account.ID, team.ID), nil) + r.ServeHTTP(w, req) + assert.Equal(s.T(), http.StatusOK, w.Code) } @@ -120,12 +148,16 @@ func (s *TeamHandlerTestSuite) TestUpdate_Success() { }) w := httptest.NewRecorder() - body := `{"team":{"name":"updated-team","description":"updated description"}}` + body := `{"team":{"name":"updated-team","description":"updated description","allow_auto_assign":false}}` req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/teams/%d", s.account.ID, team.ID), bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + assert.NoError(s.T(), json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), "updated-team", payload["name"]) + assert.Equal(s.T(), false, payload["allow_auto_assign"]) } func (s *TeamHandlerTestSuite) TestDelete_Success() { @@ -142,7 +174,7 @@ func (s *TeamHandlerTestSuite) TestDelete_Success() { req, _ := http.NewRequest("DELETE", fmt.Sprintf("/api/v1/accounts/%d/teams/%d", s.account.ID, team.ID), nil) r.ServeHTTP(w, req) - assert.Equal(s.T(), http.StatusNoContent, w.Code) + assert.Equal(s.T(), http.StatusOK, w.Code) } func (s *TeamHandlerTestSuite) TestGet_BadRequest_InvalidID() { @@ -157,4 +189,4 @@ func (s *TeamHandlerTestSuite) TestGet_BadRequest_InvalidID() { r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} \ No newline at end of file +} diff --git a/internal/repository/agent_repo.go b/internal/repository/agent_repo.go index eb8b64e5..f9609002 100644 --- a/internal/repository/agent_repo.go +++ b/internal/repository/agent_repo.go @@ -35,6 +35,7 @@ type AgentDetail struct { AutoOffline bool `json:"auto_offline"` InvitedBy uint `json:"invited_by"` AccountUserID uint `json:"account_user_id"` + CustomRoleID uint `json:"custom_role_id,omitempty"` } // ListByAccount retrieves all agents (users) for an account with pagination. @@ -92,6 +93,7 @@ func (r *AgentRepo) ListByAccount(ctx context.Context, accountID uint, offset, l AutoOffline: au.AutoOffline, InvitedBy: au.InvitedBy, AccountUserID: au.ID, + CustomRoleID: au.CustomRoleID, } details = append(details, d) } @@ -121,13 +123,14 @@ func (r *AgentRepo) FindAgentByID(ctx context.Context, userID, accountID uint) ( AutoOffline: au.AutoOffline, InvitedBy: au.InvitedBy, AccountUserID: au.ID, + CustomRoleID: au.CustomRoleID, }, nil } // CreateAgent adds a user to an account (creates AccountUser). // Reference: Chatwoot agents_controller.rb#create → AgentBuilder.new.perform // If the user does not exist, creates the user first, then creates the AccountUser. -func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool) (*AgentDetail, error) { +func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool, customRoleID uint) (*AgentDetail, error) { // Find or create the user var user model.User err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error @@ -160,14 +163,15 @@ func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID u // Create AccountUser au := model.AccountUser{ - UserID: user.ID, - AccountID: accountID, - Role: role, + UserID: user.ID, + AccountID: accountID, + Role: role, + CustomRoleID: customRoleID, Availability: availability, - AutoOffline: autoOffline, - InvitedBy: inviterID, + AutoOffline: autoOffline, + InvitedBy: inviterID, } - if err := r.db.WithContext(ctx).Create(&au).Error; err != nil { + if err := r.db.WithContext(ctx).Select("UserID", "AccountID", "Role", "CustomRoleID", "Availability", "AutoOffline", "InvitedBy").Create(&au).Error; err != nil { return nil, err } @@ -175,14 +179,16 @@ func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID u User: user, Role: au.Role, Availability: au.Availability, + AutoOffline: au.AutoOffline, InvitedBy: au.InvitedBy, AccountUserID: au.ID, + CustomRoleID: au.CustomRoleID, }, nil } // UpdateAgent updates both the User (name) and AccountUser (role, availability, auto_offline). // Reference: Chatwoot agents_controller.rb#update → agent.update!(name) + current_account_user.update!(role, availability, auto_offline) -func (r *AgentRepo) UpdateAgent(ctx context.Context, userID, accountID uint, name, role, availability string, autoOffline bool) (*AgentDetail, error) { +func (r *AgentRepo) UpdateAgent(ctx context.Context, userID, accountID uint, name, role, availability string, autoOffline bool, customRoleID *uint, customRoleIDSet bool) (*AgentDetail, error) { // Update user name if provided if name != "" { if err := r.db.WithContext(ctx). @@ -201,6 +207,13 @@ func (r *AgentRepo) UpdateAgent(ctx context.Context, userID, accountID uint, nam if availability != "" { updates["availability"] = availability } + if customRoleIDSet { + if customRoleID == nil { + updates["custom_role_id"] = 0 + } else { + updates["custom_role_id"] = *customRoleID + } + } // Chatwoot always sends auto_offline (even false) via .compact filter // so we always update it when the request includes it updates["auto_offline"] = autoOffline @@ -259,7 +272,7 @@ func (r *AgentRepo) BulkCreateAgents(ctx context.Context, accountID uint, invite name = email[:atIdx] } - detail, err := r.CreateAgent(ctx, accountID, inviterID, name, email, "agent", "offline", false) + detail, err := r.CreateAgent(ctx, accountID, inviterID, name, email, "agent", "offline", false, 0) if err != nil { // Silently skip — Chatwoot rescues ActiveRecord::RecordInvalid and logs continue @@ -288,4 +301,4 @@ func (r *AgentRepo) CountByAccount(ctx context.Context, accountID uint) (int64, Where("account_id = ?", accountID). Count(&count).Error return count, err -} \ No newline at end of file +} diff --git a/internal/repository/team_repo.go b/internal/repository/team_repo.go index dbeddc76..8eaf8daa 100644 --- a/internal/repository/team_repo.go +++ b/internal/repository/team_repo.go @@ -62,7 +62,7 @@ func (r *TeamRepo) ListByAccount(ctx context.Context, accountID uint, offset, li // Create creates a new team. func (r *TeamRepo) Create(ctx context.Context, team *model.Team) error { - return r.db.WithContext(ctx).Create(team).Error + return r.db.WithContext(ctx).Select("AccountID", "Name", "Description", "AllowAutoAssignment").Create(team).Error } // Update updates a team. diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index 43b36ad1..5ee28b54 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" @@ -25,6 +26,13 @@ func NewAgentService(agentRepo *repository.AgentRepo, db *gorm.DB) *AgentService return &AgentService{agentRepo: agentRepo, db: db} } +func (s *AgentService) DB() *gorm.DB { + if s == nil { + return nil + } + return s.db +} + // CreateAgentRequest is the DTO for creating/adding an agent to an account. // Reference: Chatwoot agents_controller.rb#create → new_agent_params (email, name, role, availability, auto_offline) type CreateAgentRequest struct { @@ -33,17 +41,37 @@ type CreateAgentRequest struct { Role string `json:"role" validate:"omitempty,oneof=agent administrator"` Availability string `json:"availability" validate:"omitempty,oneof=online offline busy"` AutoOffline bool `json:"auto_offline"` + CustomRoleID *uint `json:"custom_role_id,omitempty"` } // UpdateAgentRequest is the DTO for updating an agent. // Reference: Chatwoot agents_controller.rb#update → agent_params (name on User, role/availability/auto_offline on AccountUser) type UpdateAgentRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=1"` - Role string `json:"role,omitempty" validate:"omitempty,oneof=agent administrator"` - Availability string `json:"availability,omitempty" validate:"omitempty,oneof=online offline busy"` - AutoOffline bool `json:"auto_offline"` + Name string `json:"name,omitempty" validate:"omitempty,min=1"` + Role string `json:"role,omitempty" validate:"omitempty,oneof=agent administrator"` + Availability string `json:"availability,omitempty" validate:"omitempty,oneof=online offline busy"` + AutoOffline bool `json:"auto_offline"` + CustomRoleID *uint `json:"custom_role_id,omitempty"` + customRoleSet bool } +func (r *UpdateAgentRequest) UnmarshalJSON(data []byte) error { + type alias UpdateAgentRequest + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + var decoded alias + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *r = UpdateAgentRequest(decoded) + _, r.customRoleSet = raw["custom_role_id"] + return nil +} + +func (r UpdateAgentRequest) CustomRoleIDSet() bool { return r.customRoleSet } + // BulkCreateAgentRequest is the DTO for bulk creating agents. // Reference: Chatwoot agents_controller.rb#bulk_create → params[:emails] type BulkCreateAgentRequest struct { @@ -76,7 +104,11 @@ func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uin availability = "offline" } - detail, err := s.agentRepo.CreateAgent(ctx, accountID, inviterID, req.Name, req.Email, role, availability, req.AutoOffline) + customRoleID := uint(0) + if req.CustomRoleID != nil { + customRoleID = *req.CustomRoleID + } + detail, err := s.agentRepo.CreateAgent(ctx, accountID, inviterID, req.Name, req.Email, role, availability, req.AutoOffline, customRoleID) if err != nil { if errors.Is(err, repository.ErrAlreadyMember) { return nil, repository.ErrAlreadyMember @@ -94,7 +126,7 @@ func (s *AgentService) Update(ctx context.Context, userID, accountID uint, req U return nil, fmt.Errorf("validation: %w", err) } - return s.agentRepo.UpdateAgent(ctx, userID, accountID, req.Name, req.Role, req.Availability, req.AutoOffline) + return s.agentRepo.UpdateAgent(ctx, userID, accountID, req.Name, req.Role, req.Availability, req.AutoOffline, req.CustomRoleID, req.CustomRoleIDSet()) } // Delete removes an agent from an account (deletes AccountUser, optionally deletes User). @@ -159,4 +191,4 @@ func (s *AgentService) CanAddAgents(ctx context.Context, accountID uint, count i } // -1 means unlimited return available < 0 || available >= count, nil -} \ No newline at end of file +} diff --git a/internal/service/team_service.go b/internal/service/team_service.go index 72725ce3..488d66c3 100644 --- a/internal/service/team_service.go +++ b/internal/service/team_service.go @@ -16,9 +16,9 @@ import ( // TeamService implements business logic for Team operations. // Reference: Chatwoot app/controllers/api/v1/teams_controller.rb type TeamService struct { - teamRepo *repository.TeamRepo - teamMemberRepo *repository.TeamMemberRepo - db *gorm.DB + teamRepo *repository.TeamRepo + teamMemberRepo *repository.TeamMemberRepo + db *gorm.DB } // NewTeamService creates a new Team service. @@ -26,17 +26,26 @@ func NewTeamService(teamRepo *repository.TeamRepo, teamMemberRepo *repository.Te return &TeamService{teamRepo: teamRepo, teamMemberRepo: teamMemberRepo, db: db} } +func (s *TeamService) DB() *gorm.DB { + if s == nil { + return nil + } + return s.db +} + // CreateTeamRequest is the DTO for creating a team. type CreateTeamRequest struct { - Name string `json:"name" validate:"required,min=2"` - Description string `json:"description,omitempty"` + Name string `json:"name" validate:"required,min=2"` + Description string `json:"description,omitempty"` + AllowAutoAssign *bool `json:"allow_auto_assign,omitempty"` AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"` } // UpdateTeamRequest is the DTO for updating a team. type UpdateTeamRequest struct { - Name string `json:"name,omitempty" validate:"omitempty,min=2"` - Description string `json:"description,omitempty"` + Name string `json:"name,omitempty" validate:"omitempty,min=2"` + Description string `json:"description,omitempty"` + AllowAutoAssign *bool `json:"allow_auto_assign,omitempty"` AllowAutoAssignment *bool `json:"allow_auto_assignment,omitempty"` } @@ -66,14 +75,16 @@ func (s *TeamService) Create(ctx context.Context, accountID uint, req CreateTeam } autoAssign := true // default - if req.AllowAutoAssignment != nil { + if req.AllowAutoAssign != nil { + autoAssign = *req.AllowAutoAssign + } else if req.AllowAutoAssignment != nil { autoAssign = *req.AllowAutoAssignment } team := &model.Team{ - AccountID: accountID, - Name: req.Name, - Description: req.Description, + AccountID: accountID, + Name: req.Name, + Description: req.Description, AllowAutoAssignment: autoAssign, } @@ -81,6 +92,12 @@ func (s *TeamService) Create(ctx context.Context, accountID uint, req CreateTeam applogger.L().Errorf("failed to create team: %v", err) return nil, fmt.Errorf("failed to create team: %w", err) } + if !autoAssign { + team.AllowAutoAssignment = false + if err := s.db.WithContext(ctx).Model(team).Update("allow_auto_assignment", false).Error; err != nil { + return nil, fmt.Errorf("failed to update team auto assignment: %w", err) + } + } return team, nil } @@ -100,7 +117,9 @@ func (s *TeamService) Update(ctx context.Context, id, accountID uint, req Update } // Allow empty description to clear it team.Description = req.Description - if req.AllowAutoAssignment != nil { + if req.AllowAutoAssign != nil { + team.AllowAutoAssignment = *req.AllowAutoAssign + } else if req.AllowAutoAssignment != nil { team.AllowAutoAssignment = *req.AllowAutoAssignment } @@ -140,8 +159,8 @@ func (s *TeamService) AddMembers(ctx context.Context, teamID, accountID uint, us continue } members = append(members, model.TeamMember{ - TeamID: teamID, - UserID: uid, + TeamID: teamID, + UserID: uid, AvailabilityStatus: "offline", }) }