From b197e54d9f9f373852c56027c1d13715aa18eda8 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 06:03:44 +0800 Subject: [PATCH] feat(capacity): align chatwoot inbox capacity limits --- cmd/route_parity/main.go | 12 + docs/parity/gochat_routes.txt | 10 +- docs/parity/route_parity.md | 15 +- .../handler/api/v1/agent_capacity_handler.go | 304 ++++++++++++++++-- .../api/v1/agent_capacity_handler_test.go | 81 ++++- internal/model/account_user.go | 32 +- internal/model/agent_capacity_policy.go | 17 +- .../repository/agent_capacity_policy_repo.go | 85 ++++- .../service/agent_capacity_policy_service.go | 165 +++++++++- .../agent_capacity_policy_service_test.go | 80 ++++- ...2_add_agent_capacity_policy_links.down.sql | 9 + ...022_add_agent_capacity_policy_links.up.sql | 37 +++ 12 files changed, 783 insertions(+), 64 deletions(-) create mode 100644 migrations/000022_add_agent_capacity_policy_links.down.sql create mode 100644 migrations/000022_add_agent_capacity_policy_links.up.sql diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index f5ef7697..68c1ae31 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -186,6 +186,18 @@ var criticalRoutes = []route{ {Method: "DELETE", Path: "/api/v1/accounts/:account_id/sla_policies/:id", Controller: "api/v1/accounts/sla_policies#destroy", Source: "routes.rb:121"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/custom_roles/", Controller: "api/v1/accounts/custom_roles#index", Source: "routes.rb:122"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/", Controller: "api/v1/accounts/agent_capacity_policies#index", Source: "routes.rb:123"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/", Controller: "api/v1/accounts/agent_capacity_policies#create", Source: "routes.rb:123"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:id", Controller: "api/v1/accounts/agent_capacity_policies#show", Source: "routes.rb:123"}, + {Method: "PUT", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:id", Controller: "api/v1/accounts/agent_capacity_policies#update", Source: "routes.rb:123"}, + {Method: "PATCH", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:id", Controller: "api/v1/accounts/agent_capacity_policies#update", Source: "routes.rb:123"}, + {Method: "DELETE", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:id", Controller: "api/v1/accounts/agent_capacity_policies#destroy", Source: "routes.rb:123"}, + {Method: "GET", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users", Controller: "api/v1/accounts/agent_capacity_policies/users#index", Source: "routes.rb:125"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users", Controller: "api/v1/accounts/agent_capacity_policies/users#create", Source: "routes.rb:125"}, + {Method: "DELETE", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users/:id", Controller: "api/v1/accounts/agent_capacity_policies/users#destroy", Source: "routes.rb:125"}, + {Method: "POST", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits", Controller: "api/v1/accounts/agent_capacity_policies/inbox_limits#create", Source: "routes.rb:126"}, + {Method: "PUT", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id", Controller: "api/v1/accounts/agent_capacity_policies/inbox_limits#update", Source: "routes.rb:126"}, + {Method: "PATCH", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id", Controller: "api/v1/accounts/agent_capacity_policies/inbox_limits#update", Source: "routes.rb:126"}, + {Method: "DELETE", Path: "/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id", Controller: "api/v1/accounts/agent_capacity_policies/inbox_limits#destroy", Source: "routes.rb:126"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/csat_survey_responses/", Controller: "api/v1/accounts/csat_survey_responses#index", Source: "routes.rb:219"}, {Method: "GET", Path: "/api/v1/accounts/:account_id/csat_survey_responses/metrics", Controller: "api/v1/accounts/csat_survey_responses#metrics", Source: "routes.rb:221"}, diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 17b7d4d0..9bd1a74f 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -5,6 +5,8 @@ DELETE /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/avatar DELETE /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/:rule_id DELETE /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:trigger_config_id DELETE /api/v1/accounts/:account_id/agent_capacity_policies/:id +DELETE /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id +DELETE /api/v1/accounts/:account_id/agent_capacity_policies/:id/users/:user_id DELETE /api/v1/accounts/:account_id/agents/:agent_id DELETE /api/v1/accounts/:account_id/assignment_policies/:policy_id DELETE /api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes/:inbox_id @@ -123,6 +125,7 @@ GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/ GET /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:trigger_config_id GET /api/v1/accounts/:account_id/agent_capacity_policies/ GET /api/v1/accounts/:account_id/agent_capacity_policies/:id +GET /api/v1/accounts/:account_id/agent_capacity_policies/:id/users GET /api/v1/accounts/:account_id/agents GET /api/v1/accounts/:account_id/agents/:agent_id GET /api/v1/accounts/:account_id/applied_slas/download @@ -439,6 +442,8 @@ GET /widget/widget/:website_token/theme_config GET /widget/widget/:website_token/uploads/:upload_uuid GET /ws PATCH /api/v1/accounts/:account_id/agent_bot_inboxes/:agent_bot_inbox_id/status +PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id +PATCH /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id PATCH /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id PATCH /api/v1/accounts/:account_id/companies/:company_id PATCH /api/v1/accounts/:account_id/contacts/:contact_id @@ -488,6 +493,8 @@ POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_secret POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/reset_token POST /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/ POST /api/v1/accounts/:account_id/agent_capacity_policies/ +POST /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits +POST /api/v1/accounts/:account_id/agent_capacity_policies/:id/users POST /api/v1/accounts/:account_id/agents POST /api/v1/accounts/:account_id/agents/bulk_assign POST /api/v1/accounts/:account_id/agents/bulk_create @@ -751,6 +758,7 @@ PUT /api/v1/accounts/:account_id/agent_bots/:agent_bot_id PUT /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/bot_rules/:rule_id PUT /api/v1/accounts/:account_id/agent_bots/:agent_bot_id/trigger_configs/:trigger_config_id PUT /api/v1/accounts/:account_id/agent_capacity_policies/:id +PUT /api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id PUT /api/v1/accounts/:account_id/agents/:agent_id PUT /api/v1/accounts/:account_id/assignment_policies/:policy_id PUT /api/v1/accounts/:account_id/assignment_policies_v2/:id @@ -819,4 +827,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 821 +TOTAL: 829 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 98e23d9b..47839de9 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 262 exact, 0 method-compatible, 0 parameter-compatible, 0 missing out of 262 tracked critical routes. +Summary: 267 exact, 0 method-compatible, 7 parameter-compatible, 0 missing out of 274 tracked critical routes. ## Missing Critical Routes @@ -29,12 +29,19 @@ These routes exist with equivalent method and path shape but different parameter | Method | Chatwoot Path | GoChat Match | Controller | Source | Status | | --- | --- | --- | --- | --- | --- | -| - | - | - | - | - | none | +| DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#destroy` | `routes.rb:126` | parameter-compatible | +| DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users/:user_id` | `api/v1/accounts/agent_capacity_policies/users#destroy` | `routes.rb:125` | parameter-compatible | +| GET | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#index` | `routes.rb:125` | parameter-compatible | +| PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible | +| POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits` | `api/v1/accounts/agent_capacity_policies/inbox_limits#create` | `routes.rb:126` | parameter-compatible | +| POST | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/users` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/users` | `api/v1/accounts/agent_capacity_policies/users#create` | `routes.rb:125` | parameter-compatible | +| PUT | `/api/v1/accounts/:account_id/agent_capacity_policies/:agent_capacity_policy_id/inbox_limits/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id/inbox_limits/:limit_id` | `api/v1/accounts/agent_capacity_policies/inbox_limits#update` | `routes.rb:126` | parameter-compatible | ## Exact Critical Routes | Method | Chatwoot Path | GoChat Match | Controller | Source | Status | | --- | --- | --- | --- | --- | --- | +| DELETE | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#destroy` | `routes.rb:123` | exact | | DELETE | `/api/v1/accounts/:account_id/agents/:agent_id` | `/api/v1/accounts/:account_id/agents/:agent_id` | `api/v1/accounts/agents#destroy` | `routes.rb:59` | exact | | DELETE | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `api/v1/accounts/assignment_policies#destroy` | `routes.rb:306` | exact | | DELETE | `/api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes/:inbox_id` | `/api/v1/accounts/:account_id/assignment_policies/:policy_id/inboxes/:inbox_id` | `api/v1/accounts/assignment_policies/inboxes#destroy` | `routes.rb:307` | exact | @@ -68,6 +75,7 @@ These routes exist with equivalent method and path shape but different parameter | DELETE | `/api/v1/widget/labels/:label_id` | `/api/v1/widget/labels/:label_id` | `api/v1/widget/labels#destroy` | `routes.rb:464` | exact | | GET | `/api/v1/accounts/:account_id` | `/api/v1/accounts/:account_id` | `api/v1/accounts#show` | `routes.rb:47` | exact | | GET | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `api/v1/accounts/agent_capacity_policies#index` | `routes.rb:123` | exact | +| GET | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#show` | `routes.rb:123` | exact | | GET | `/api/v1/accounts/:account_id/agents` | `/api/v1/accounts/:account_id/agents` | `api/v1/accounts/agents#index` | `routes.rb:59` | exact | | GET | `/api/v1/accounts/:account_id/assignable_agents` | `/api/v1/accounts/:account_id/assignable_agents` | `api/v1/accounts/assignable_agents#index` | `routes.rb:104` | exact | | GET | `/api/v1/accounts/:account_id/assignment_policies/` | `/api/v1/accounts/:account_id/assignment_policies/` | `api/v1/accounts/assignment_policies#index` | `routes.rb:306` | exact | @@ -179,11 +187,13 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `public/api/v1/inboxes/conversations#index` | `routes.rb:573` | exact | | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `public/api/v1/inboxes/conversations#show` | `routes.rb:573` | exact | | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `public/api/v1/inboxes/messages#index` | `routes.rb:580` | exact | +| PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact | | PATCH | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#update` | `routes.rb:278` | exact | | PATCH | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `/api/v1/accounts/:account_id/teams/:team_id/team_members/` | `api/v1/accounts/teams/team_members#update` | `routes.rb:300` | exact | | PATCH | `/api/v1/widget/contact/set_user` | `/api/v1/widget/contact/set_user` | `api/v1/widget/contact#set_user` | `routes.rb:461` | exact | | POST | `/api/v1/accounts/` | `/api/v1/accounts/` | `api/v1/accounts#create` | `routes.rb:47` | exact | | POST | `/api/v1/accounts/:account_id/actions/contact_merge` | `/api/v1/accounts/:account_id/actions/contact_merge` | `api/v1/accounts/actions/contact_merges#create` | `routes.rb:55` | exact | +| POST | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `/api/v1/accounts/:account_id/agent_capacity_policies/` | `api/v1/accounts/agent_capacity_policies#create` | `routes.rb:123` | exact | | POST | `/api/v1/accounts/:account_id/agents` | `/api/v1/accounts/:account_id/agents` | `api/v1/accounts/agents#create` | `routes.rb:59` | exact | | POST | `/api/v1/accounts/:account_id/agents/bulk_create` | `/api/v1/accounts/:account_id/agents/bulk_create` | `api/v1/accounts/agents#bulk_create` | `routes.rb:60` | exact | | POST | `/api/v1/accounts/:account_id/assignment_policies/` | `/api/v1/accounts/:account_id/assignment_policies/` | `api/v1/accounts/assignment_policies#create` | `routes.rb:306` | exact | @@ -270,6 +280,7 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing` | `public/api/v1/inboxes/conversations#toggle_typing` | `routes.rb:576` | exact | | POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen` | `public/api/v1/inboxes/conversations#update_last_seen` | `routes.rb:577` | exact | | PUT | `/api/v1/accounts/:account_id` | `/api/v1/accounts/:account_id` | `api/v1/accounts#update` | `routes.rb:47` | exact | +| PUT | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact | | PUT | `/api/v1/accounts/:account_id/agents/:agent_id` | `/api/v1/accounts/:account_id/agents/:agent_id` | `api/v1/accounts/agents#update` | `routes.rb:59` | exact | | PUT | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `/api/v1/accounts/:account_id/assignment_policies/:policy_id` | `api/v1/accounts/assignment_policies#update` | `routes.rb:306` | exact | | PUT | `/api/v1/accounts/:account_id/automation_rules/:automation_id` | `/api/v1/accounts/:account_id/automation_rules/:automation_id` | `api/v1/accounts/automation_rules#update` | `routes.rb:115` | exact | diff --git a/internal/handler/api/v1/agent_capacity_handler.go b/internal/handler/api/v1/agent_capacity_handler.go index 61ef7295..cc7a9221 100644 --- a/internal/handler/api/v1/agent_capacity_handler.go +++ b/internal/handler/api/v1/agent_capacity_handler.go @@ -1,13 +1,15 @@ package v1 import ( + "encoding/json" "net/http" + "strings" "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" "github.com/gochat/gochat/pkg/response" ) @@ -31,15 +33,18 @@ func (h *AgentCapacityHandler) List(c *gin.Context) { return } - pg := pagination.Parse(c) - policies, total, err := h.svc.List(c.Request.Context(), accountID, pg.Page, pg.PerPage) + policies, _, err := h.svc.List(c.Request.Context(), accountID, 1, 1000) if err != nil { applogger.L().Errorf("List agent capacity policies for account %d: %v", accountID, err) handleServiceError(c, err) return } - response.OKWithMeta(c, policies, pg.Page, pg.PerPage, total) + payload := make([]gin.H, 0, len(policies)) + for i := range policies { + payload = append(payload, serializeAgentCapacityPolicy(&policies[i])) + } + c.JSON(http.StatusOK, payload) } // Create creates a new agent capacity policy for an account. @@ -52,22 +57,20 @@ func (h *AgentCapacityHandler) Create(c *gin.Context) { return } - var wrapper struct { - AgentCapacityPolicy service.CreateAgentCapacityPolicyRequest `json:"agent_capacity_policy"` - } - if err := c.ShouldBindJSON(&wrapper); err != nil { + req, err := bindAgentCapacityPolicyCreate(c) + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - policy, err := h.svc.Create(c.Request.Context(), accountID, wrapper.AgentCapacityPolicy) + policy, err := h.svc.Create(c.Request.Context(), accountID, req) if err != nil { applogger.L().Errorf("Create agent capacity policy for account %d: %v", accountID, err) - handleServiceError(c, err) + handleAgentCapacityError(c, err) return } - response.Created(c, policy) + c.JSON(http.StatusOK, serializeAgentCapacityPolicy(policy)) } // Get returns a single agent capacity policy by ID. @@ -92,7 +95,7 @@ func (h *AgentCapacityHandler) Get(c *gin.Context) { return } - response.OK(c, policy) + c.JSON(http.StatusOK, serializeAgentCapacityPolicy(policy)) } // Update updates an existing agent capacity policy. @@ -110,8 +113,8 @@ func (h *AgentCapacityHandler) Update(c *gin.Context) { return } - var req service.UpdateAgentCapacityPolicyRequest - if err := c.ShouldBindJSON(&req); err != nil { + req, err := bindAgentCapacityPolicyUpdate(c) + if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } @@ -119,11 +122,11 @@ func (h *AgentCapacityHandler) Update(c *gin.Context) { policy, svcErr := h.svc.Update(c.Request.Context(), id, accountID, req) if svcErr != nil { applogger.L().Errorf("Update agent capacity policy %d for account %d: %v", id, accountID, svcErr) - handleServiceError(c, svcErr) + handleAgentCapacityError(c, svcErr) return } - response.OK(c, policy) + c.JSON(http.StatusOK, serializeAgentCapacityPolicy(policy)) } // Delete deletes an agent capacity policy. @@ -147,7 +150,153 @@ func (h *AgentCapacityHandler) Delete(c *gin.Context) { return } - response.NoContent(c) + c.Status(http.StatusOK) +} + +func (h *AgentCapacityHandler) CreateInboxLimit(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + policyID, err := parseUintParam(c, "id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") + return + } + var req service.CreateInboxCapacityLimitRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + limit, svcErr := h.svc.CreateInboxCapacityLimit(c.Request.Context(), policyID, accountID, req) + if svcErr != nil { + handleAgentCapacityError(c, svcErr) + return + } + c.JSON(http.StatusOK, serializeInboxCapacityLimit(limit, false)) +} + +func (h *AgentCapacityHandler) UpdateInboxLimit(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + policyID, err := parseUintParam(c, "id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") + return + } + limitID, err := parseUintParam(c, "limit_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid limit id") + return + } + var req service.UpdateInboxCapacityLimitRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + limit, svcErr := h.svc.UpdateInboxCapacityLimit(c.Request.Context(), policyID, accountID, limitID, req) + if svcErr != nil { + handleAgentCapacityError(c, svcErr) + return + } + c.JSON(http.StatusOK, serializeInboxCapacityLimit(limit, true)) +} + +func (h *AgentCapacityHandler) DeleteInboxLimit(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + policyID, err := parseUintParam(c, "id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") + return + } + limitID, err := parseUintParam(c, "limit_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid limit id") + return + } + if svcErr := h.svc.DeleteInboxCapacityLimit(c.Request.Context(), policyID, accountID, limitID); svcErr != nil { + handleAgentCapacityError(c, svcErr) + return + } + c.Status(http.StatusNoContent) +} + +func (h *AgentCapacityHandler) ListUsers(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + policyID, err := parseUintParam(c, "id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") + return + } + users, svcErr := h.svc.ListUsers(c.Request.Context(), policyID, accountID) + if svcErr != nil { + handleAgentCapacityError(c, svcErr) + return + } + payload := make([]gin.H, 0, len(users)) + for i := range users { + payload = append(payload, serializeAgentCapacityUser(&users[i], accountID)) + } + c.JSON(http.StatusOK, payload) +} + +func (h *AgentCapacityHandler) CreateUser(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + policyID, err := parseUintParam(c, "id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") + return + } + var req service.AssignCapacityPolicyUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) + return + } + user, svcErr := h.svc.AssignUser(c.Request.Context(), policyID, accountID, req) + if svcErr != nil { + handleAgentCapacityError(c, svcErr) + return + } + c.JSON(http.StatusOK, serializeAgentCapacityUser(user, accountID)) +} + +func (h *AgentCapacityHandler) DeleteUser(c *gin.Context) { + accountID := getAccountID(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified") + return + } + policyID, err := parseUintParam(c, "id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") + return + } + userID, err := parseUintParam(c, "user_id") + if err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid user id") + return + } + if svcErr := h.svc.RemoveUser(c.Request.Context(), policyID, accountID, userID); svcErr != nil { + handleAgentCapacityError(c, svcErr) + return + } + c.Status(http.StatusOK) } // RegisterAgentCapacityRoutes registers agent capacity policy routes on a gin.RouterGroup. @@ -158,6 +307,125 @@ func RegisterAgentCapacityRoutes(rg *gin.RouterGroup, h *AgentCapacityHandler) { policies.POST("/", h.Create) policies.GET("/:id", h.Get) policies.PUT("/:id", h.Update) + policies.PATCH("/:id", h.Update) policies.DELETE("/:id", h.Delete) + policies.GET("/:id/users", h.ListUsers) + policies.POST("/:id/users", h.CreateUser) + policies.DELETE("/:id/users/:user_id", h.DeleteUser) + policies.POST("/:id/inbox_limits", h.CreateInboxLimit) + policies.PUT("/:id/inbox_limits/:limit_id", h.UpdateInboxLimit) + policies.PATCH("/:id/inbox_limits/:limit_id", h.UpdateInboxLimit) + policies.DELETE("/:id/inbox_limits/:limit_id", h.DeleteInboxLimit) } -} \ No newline at end of file +} + +func bindAgentCapacityPolicyCreate(c *gin.Context) (service.CreateAgentCapacityPolicyRequest, error) { + var raw map[string]json.RawMessage + if err := c.ShouldBindJSON(&raw); err != nil { + return service.CreateAgentCapacityPolicyRequest{}, err + } + var req service.CreateAgentCapacityPolicyRequest + if wrapped, ok := raw["agent_capacity_policy"]; ok { + return req, json.Unmarshal(wrapped, &req) + } + buf, err := json.Marshal(raw) + if err != nil { + return req, err + } + return req, json.Unmarshal(buf, &req) +} + +func bindAgentCapacityPolicyUpdate(c *gin.Context) (service.UpdateAgentCapacityPolicyRequest, error) { + var raw map[string]json.RawMessage + if err := c.ShouldBindJSON(&raw); err != nil { + return service.UpdateAgentCapacityPolicyRequest{}, err + } + var req service.UpdateAgentCapacityPolicyRequest + if wrapped, ok := raw["agent_capacity_policy"]; ok { + return req, json.Unmarshal(wrapped, &req) + } + buf, err := json.Marshal(raw) + if err != nil { + return req, err + } + return req, json.Unmarshal(buf, &req) +} + +func serializeAgentCapacityPolicy(policy *model.AgentCapacityPolicy) gin.H { + limits := make([]gin.H, 0, len(policy.InboxCapacityLimits)) + for i := range policy.InboxCapacityLimits { + limit := policy.InboxCapacityLimits[i] + limits = append(limits, gin.H{ + "id": limit.ID, + "inbox_id": limit.InboxID, + "conversation_limit": limit.ConversationLimit, + }) + } + return gin.H{ + "id": policy.ID, + "name": policy.Name, + "description": policy.Description, + "exclusion_rules": policy.ExclusionRules, + "created_at": policy.CreatedAt.Unix(), + "updated_at": policy.UpdatedAt.Unix(), + "account_id": policy.AccountID, + "assigned_agent_count": len(policy.AccountUsers), + "inbox_capacity_limits": limits, + } +} + +func serializeInboxCapacityLimit(limit *model.InboxCapacityLimit, includeInboxName bool) gin.H { + payload := gin.H{ + "id": limit.ID, + "inbox_id": limit.InboxID, + "agent_capacity_policy_id": limit.AgentCapacityPolicyID, + "conversation_limit": limit.ConversationLimit, + "created_at": limit.CreatedAt.Unix(), + "updated_at": limit.UpdatedAt.Unix(), + } + if includeInboxName { + payload["inbox_name"] = limit.Inbox.Name + } + return payload +} + +func serializeAgentCapacityUser(user *model.User, accountID uint) gin.H { + availableName := user.DisplayName + if availableName == "" { + availableName = user.Name + } + if availableName == "" { + availableName = user.Email + } + return gin.H{ + "access_token": nil, + "account_id": accountID, + "available_name": availableName, + "avatar_url": user.AvatarURL, + "confirmed": user.ConfirmedAt != nil, + "display_name": user.DisplayName, + "message_signature": user.MessageSignature, + "email": user.Email, + "id": user.ID, + "inviter_id": nil, + "name": user.Name, + "provider": user.Provider, + "pubsub_token": user.PubsubToken, + "role": user.Role, + "ui_settings": user.UISettings, + "uid": user.UID, + "type": user.Type, + } +} + +func handleAgentCapacityError(c *gin.Context, err error) { + if err == nil { + return + } + lower := strings.ToLower(err.Error()) + if strings.Contains(lower, "already been assigned") || strings.Contains(lower, "greater than or equal") { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error()) + return + } + handleServiceError(c, err) +} diff --git a/internal/handler/api/v1/agent_capacity_handler_test.go b/internal/handler/api/v1/agent_capacity_handler_test.go index f40cd536..87731833 100644 --- a/internal/handler/api/v1/agent_capacity_handler_test.go +++ b/internal/handler/api/v1/agent_capacity_handler_test.go @@ -1,6 +1,8 @@ package v1 import ( + "bytes" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -31,7 +33,7 @@ func (s *AgentCapacityHandlerTestSuite) SetupSuite() { Logger: logger.Default.LogMode(logger.Silent), }) s.Require().NoError(err) - s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AgentCapacityPolicy{})) + s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.AgentCapacityPolicy{}, &model.InboxCapacityLimit{})) s.db = db repo := repository.NewAgentCapacityPolicyRepo(db) @@ -130,4 +132,79 @@ func (s *AgentCapacityHandlerTestSuite) TestDelete_BadRequest_InvalidID() { r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusBadRequest, w.Code) -} \ No newline at end of file +} + +func (s *AgentCapacityHandlerTestSuite) TestChatwootPolicyInboxLimitAndUserFlow() { + r := gin.New() + api := r.Group("/api/v1/accounts/:account_id") + RegisterAgentCapacityRoutes(api, s.handler) + + inbox := &model.Inbox{AccountID: s.account.ID, Name: "Priority", ChannelType: "web_widget"} + s.Require().NoError(s.db.Create(inbox).Error) + user := &model.User{AccountID: s.account.ID, Name: "Capacity Agent", Email: "capacity-agent@example.com", Password: "secret", Role: "agent"} + s.Require().NoError(s.db.Create(user).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.account.ID, UserID: user.ID, Role: "agent"}).Error) + + w := httptest.NewRecorder() + body := `{"agent_capacity_policy":{"name":"Priority policy","description":"VIP","exclusion_rules":{"exclude_older_than_hours":24,"excluded_labels":["spam"]}}}` + req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/", s.account.ID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + var policy map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &policy)) + s.Require().NotContains(policy, "success") + s.Require().Equal("Priority policy", policy["name"]) + s.Require().Equal(float64(0), policy["assigned_agent_count"]) + policyID := uint(policy["id"].(float64)) + + w = httptest.NewRecorder() + body = fmt.Sprintf(`{"inbox_id":%d,"conversation_limit":7}`, inbox.ID) + req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/inbox_limits", s.account.ID, policyID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + var limit map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &limit)) + s.Require().Equal(float64(inbox.ID), limit["inbox_id"]) + s.Require().Equal(float64(7), limit["conversation_limit"]) + limitID := uint(limit["id"].(float64)) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/inbox_limits", s.account.ID, policyID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusUnprocessableEntity, w.Code) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/inbox_limits/%d", s.account.ID, policyID, limitID), bytes.NewBufferString(`{"conversation_limit":11}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &limit)) + s.Require().Equal(float64(11), limit["conversation_limit"]) + s.Require().Equal("Priority", limit["inbox_name"]) + + w = httptest.NewRecorder() + body = fmt.Sprintf(`{"user_id":%d}`, user.ID) + req, _ = http.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/users", s.account.ID, policyID), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + var assigned map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &assigned)) + s.Require().Equal(float64(user.ID), assigned["id"]) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d", s.account.ID, policyID), nil) + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusOK, w.Code) + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &policy)) + s.Require().Equal(float64(1), policy["assigned_agent_count"]) + s.Require().Len(policy["inbox_capacity_limits"].([]any), 1) + + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, fmt.Sprintf("/api/v1/accounts/%d/agent_capacity_policies/%d/inbox_limits/%d", s.account.ID, policyID, limitID), nil) + r.ServeHTTP(w, req) + s.Require().Equal(http.StatusNoContent, w.Code) +} diff --git a/internal/model/account_user.go b/internal/model/account_user.go index b0906a22..f63f7caa 100644 --- a/internal/model/account_user.go +++ b/internal/model/account_user.go @@ -15,22 +15,24 @@ import ( // AccountUser represents a user's membership in an account with a specific role. type AccountUser struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null;uniqueIndex:idx_user_account" json:"user_id"` - AccountID uint `gorm:"not null;uniqueIndex:idx_user_account" json:"account_id"` - Role string `gorm:"size:50;not null;default:'agent'" json:"role"` // agent, administrator, custom_role - CustomRoleID uint `gorm:"default:0" json:"custom_role_id,omitempty"` // 0 means no custom role; >0 references CustomRole -Availability string `gorm:"size:50;default:'offline'" json:"availability"` // online/offline/busy - AutoOffline bool `gorm:"default:true" json:"auto_offline"` // auto-offline when idle - ActiveAt *time.Time `json:"active_at,omitempty"` // last active timestamp in this account - InvitedBy uint `gorm:"default:0" json:"invited_by"` // UserID of the user who invited this member - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;uniqueIndex:idx_user_account" json:"user_id"` + AccountID uint `gorm:"not null;uniqueIndex:idx_user_account" json:"account_id"` + Role string `gorm:"size:50;not null;default:'agent'" json:"role"` // agent, administrator, custom_role + CustomRoleID uint `gorm:"default:0" json:"custom_role_id,omitempty"` // 0 means no custom role; >0 references CustomRole + AgentCapacityPolicyID *uint `gorm:"index" json:"agent_capacity_policy_id,omitempty"` // enterprise capacity policy assignment + Availability string `gorm:"size:50;default:'offline'" json:"availability"` // online/offline/busy + AutoOffline bool `gorm:"default:true" json:"auto_offline"` // auto-offline when idle + ActiveAt *time.Time `json:"active_at,omitempty"` // last active timestamp in this account + InvitedBy uint `gorm:"default:0" json:"invited_by"` // UserID of the user who invited this member + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` // Relations - User User `gorm:"foreignKey:UserID" json:"user,omitempty"` - Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` + AgentCapacityPolicy *AgentCapacityPolicy `gorm:"foreignKey:AgentCapacityPolicyID" json:"agent_capacity_policy,omitempty"` } func (AccountUser) TableName() string { return "account_users" } @@ -53,4 +55,4 @@ func (au *AccountUser) HasCustomRole() bool { // IsOnline returns true if the agent availability is "online". func (au *AccountUser) IsOnline() bool { return au.Availability == "online" -} \ No newline at end of file +} diff --git a/internal/model/agent_capacity_policy.go b/internal/model/agent_capacity_policy.go index d253220b..655c5003 100644 --- a/internal/model/agent_capacity_policy.go +++ b/internal/model/agent_capacity_policy.go @@ -17,19 +17,20 @@ type AgentCapacityPolicy struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` + Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"` + AccountUsers []AccountUser `gorm:"foreignKey:AgentCapacityPolicyID" json:"account_users,omitempty"` InboxCapacityLimits []InboxCapacityLimit `gorm:"foreignKey:AgentCapacityPolicyID" json:"inbox_capacity_limits,omitempty"` } // InboxCapacityLimit associates a capacity policy with an inbox and its conversation limit. // Reference: Chatwoot InboxCapacityLimit + P2B M11 spec type InboxCapacityLimit struct { - ID uint `gorm:"primaryKey" json:"id"` - AgentCapacityPolicyID uint `gorm:"not null;index" json:"agent_capacity_policy_id"` - InboxID uint `gorm:"not null;index" json:"inbox_id"` - ConversationLimit int `gorm:"not null;check:conversation_limit >= 0" json:"conversation_limit"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + AgentCapacityPolicyID uint `gorm:"not null;index;uniqueIndex:idx_policy_inbox_capacity_limit" json:"agent_capacity_policy_id"` + InboxID uint `gorm:"not null;index;uniqueIndex:idx_policy_inbox_capacity_limit" json:"inbox_id"` + ConversationLimit int `gorm:"not null;check:conversation_limit >= 0" json:"conversation_limit"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` AgentCapacityPolicy AgentCapacityPolicy `gorm:"foreignKey:AgentCapacityPolicyID" json:"agent_capacity_policy,omitempty"` Inbox Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"` @@ -37,4 +38,4 @@ type InboxCapacityLimit struct { func (InboxCapacityLimit) TableName() string { return "inbox_capacity_limits" } -func (AgentCapacityPolicy) TableName() string { return "agent_capacity_policies" } \ No newline at end of file +func (AgentCapacityPolicy) TableName() string { return "agent_capacity_policies" } diff --git a/internal/repository/agent_capacity_policy_repo.go b/internal/repository/agent_capacity_policy_repo.go index 974e7a29..88bc5036 100644 --- a/internal/repository/agent_capacity_policy_repo.go +++ b/internal/repository/agent_capacity_policy_repo.go @@ -22,7 +22,10 @@ func NewAgentCapacityPolicyRepo(db *gorm.DB) *AgentCapacityPolicyRepo { // FindByID retrieves an agent capacity policy by primary key. func (r *AgentCapacityPolicyRepo) FindByID(ctx context.Context, id uint) (*model.AgentCapacityPolicy, error) { var policy model.AgentCapacityPolicy - err := r.db.WithContext(ctx).First(&policy, id).Error + err := r.db.WithContext(ctx). + Preload("InboxCapacityLimits"). + Preload("AccountUsers"). + First(&policy, id).Error if err != nil { return nil, err } @@ -39,11 +42,87 @@ func (r *AgentCapacityPolicyRepo) FindByAccount(ctx context.Context, accountID u return nil, 0, err } - err := q.Offset(offset).Limit(limit). + err := q.Preload("InboxCapacityLimits").Preload("AccountUsers"). + Offset(offset).Limit(limit). Order("id ASC").Find(&policies).Error return policies, total, err } +// FindInboxByAccount retrieves an inbox scoped to an account. +func (r *AgentCapacityPolicyRepo) FindInboxByAccount(ctx context.Context, accountID, inboxID uint) (*model.Inbox, error) { + var inbox model.Inbox + if err := r.db.WithContext(ctx).Where("account_id = ? AND id = ?", accountID, inboxID).First(&inbox).Error; err != nil { + return nil, err + } + return &inbox, nil +} + +// CreateInboxCapacityLimit creates a per-inbox capacity limit under a policy. +func (r *AgentCapacityPolicyRepo) CreateInboxCapacityLimit(ctx context.Context, limit *model.InboxCapacityLimit) error { + return r.db.WithContext(ctx).Create(limit).Error +} + +// FindInboxCapacityLimitByPolicy retrieves an inbox capacity limit scoped to a policy. +func (r *AgentCapacityPolicyRepo) FindInboxCapacityLimitByPolicy(ctx context.Context, policyID, limitID uint) (*model.InboxCapacityLimit, error) { + var limit model.InboxCapacityLimit + if err := r.db.WithContext(ctx). + Preload("Inbox"). + Where("agent_capacity_policy_id = ? AND id = ?", policyID, limitID). + First(&limit).Error; err != nil { + return nil, err + } + return &limit, nil +} + +// FindInboxCapacityLimitByInbox retrieves an inbox capacity limit scoped by policy and inbox. +func (r *AgentCapacityPolicyRepo) FindInboxCapacityLimitByInbox(ctx context.Context, policyID, inboxID uint) (*model.InboxCapacityLimit, error) { + var limit model.InboxCapacityLimit + if err := r.db.WithContext(ctx). + Where("agent_capacity_policy_id = ? AND inbox_id = ?", policyID, inboxID). + First(&limit).Error; err != nil { + return nil, err + } + return &limit, nil +} + +// UpdateInboxCapacityLimit updates an inbox capacity limit. +func (r *AgentCapacityPolicyRepo) UpdateInboxCapacityLimit(ctx context.Context, limit *model.InboxCapacityLimit) error { + return r.db.WithContext(ctx).Save(limit).Error +} + +// DeleteInboxCapacityLimit deletes an inbox capacity limit. +func (r *AgentCapacityPolicyRepo) DeleteInboxCapacityLimit(ctx context.Context, limit *model.InboxCapacityLimit) error { + return r.db.WithContext(ctx).Delete(limit).Error +} + +// FindPolicyUsers retrieves users assigned to a capacity policy via account_users. +func (r *AgentCapacityPolicyRepo) FindPolicyUsers(ctx context.Context, accountID, policyID uint) ([]model.User, error) { + var users []model.User + err := r.db.WithContext(ctx). + Joins("JOIN account_users ON account_users.user_id = users.id"). + Where("account_users.account_id = ? AND account_users.agent_capacity_policy_id = ?", accountID, policyID). + Order("users.id ASC"). + Find(&users).Error + return users, err +} + +// FindAccountUser retrieves a user's membership in an account. +func (r *AgentCapacityPolicyRepo) FindAccountUser(ctx context.Context, accountID, userID uint) (*model.AccountUser, error) { + var accountUser model.AccountUser + if err := r.db.WithContext(ctx). + Preload("User"). + Where("account_id = ? AND user_id = ?", accountID, userID). + First(&accountUser).Error; err != nil { + return nil, err + } + return &accountUser, nil +} + +// UpdateAccountUser updates an account membership. +func (r *AgentCapacityPolicyRepo) UpdateAccountUser(ctx context.Context, accountUser *model.AccountUser) error { + return r.db.WithContext(ctx).Save(accountUser).Error +} + // Create creates a new agent capacity policy. func (r *AgentCapacityPolicyRepo) Create(ctx context.Context, policy *model.AgentCapacityPolicy) error { return r.db.WithContext(ctx).Create(policy).Error @@ -57,4 +136,4 @@ func (r *AgentCapacityPolicyRepo) Update(ctx context.Context, policy *model.Agen // Delete deletes an agent capacity policy by ID. func (r *AgentCapacityPolicyRepo) Delete(ctx context.Context, id uint) error { return r.db.WithContext(ctx).Delete(&model.AgentCapacityPolicy{}, id).Error -} \ No newline at end of file +} diff --git a/internal/service/agent_capacity_policy_service.go b/internal/service/agent_capacity_policy_service.go index ef22fd82..a1309fce 100644 --- a/internal/service/agent_capacity_policy_service.go +++ b/internal/service/agent_capacity_policy_service.go @@ -3,11 +3,13 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" + "gorm.io/gorm" ) // AgentCapacityPolicyService implements business logic for AgentCapacityPolicy operations. @@ -26,19 +28,32 @@ func NewAgentCapacityPolicyService(repo *repository.AgentCapacityPolicyRepo) *Ag type CreateAgentCapacityPolicyRequest struct { Name string `json:"name" validate:"required"` Description string `json:"description,omitempty"` - AssignmentLogic string `json:"assignment_logic" validate:"required"` + AssignmentLogic string `json:"assignment_logic,omitempty"` ExclusionRules json.RawMessage `json:"exclusion_rules,omitempty"` } // UpdateAgentCapacityPolicyRequest is the DTO for updating an agent capacity policy. // Request body uses Chatwoot-style wrapper: { "agent_capacity_policy": { ... } } type UpdateAgentCapacityPolicyRequest struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` AssignmentLogic string `json:"assignment_logic,omitempty"` ExclusionRules json.RawMessage `json:"exclusion_rules,omitempty"` } +type CreateInboxCapacityLimitRequest struct { + InboxID uint `json:"inbox_id"` + ConversationLimit int `json:"conversation_limit"` +} + +type UpdateInboxCapacityLimitRequest struct { + ConversationLimit int `json:"conversation_limit"` +} + +type AssignCapacityPolicyUserRequest struct { + UserID uint `json:"user_id"` +} + // validAssignmentLogic values allowed for AssignmentLogic field. var validAssignmentLogic = map[string]bool{ "round_robin": true, @@ -47,6 +62,9 @@ var validAssignmentLogic = map[string]bool{ // validateAssignmentLogic checks that the assignment logic is one of the allowed values. func validateAssignmentLogic(logic string) error { + if logic == "" { + return nil + } if !validAssignmentLogic[logic] { return fmt.Errorf("invalid assignment_logic: must be one of round_robin, least_busy") } @@ -81,16 +99,30 @@ func (s *AgentCapacityPolicyService) List(ctx context.Context, accountID uint, p // Create creates a new agent capacity policy within an account. func (s *AgentCapacityPolicyService) Create(ctx context.Context, accountID uint, req CreateAgentCapacityPolicyRequest) (*model.AgentCapacityPolicy, error) { + if req.Name == "" { + return nil, fmt.Errorf("name is required") + } + if len(req.Name) > 255 { + return nil, fmt.Errorf("name is too long") + } if err := validateAssignmentLogic(req.AssignmentLogic); err != nil { return nil, err } + assignmentLogic := req.AssignmentLogic + if assignmentLogic == "" { + assignmentLogic = "round_robin" + } + exclusionRules := req.ExclusionRules + if len(exclusionRules) == 0 { + exclusionRules = json.RawMessage(`{}`) + } policy := &model.AgentCapacityPolicy{ AccountID: accountID, Name: req.Name, Description: req.Description, - AssignmentLogic: req.AssignmentLogic, - ExclusionRules: req.ExclusionRules, + AssignmentLogic: assignmentLogic, + ExclusionRules: exclusionRules, } if err := s.repo.Create(ctx, policy); err != nil { @@ -111,11 +143,17 @@ func (s *AgentCapacityPolicyService) Update(ctx context.Context, id, accountID u } // Merge fields: only update non-empty values - if req.Name != "" { - policy.Name = req.Name + if req.Name != nil { + if *req.Name == "" { + return nil, fmt.Errorf("name is required") + } + if len(*req.Name) > 255 { + return nil, fmt.Errorf("name is too long") + } + policy.Name = *req.Name } - if req.Description != "" { - policy.Description = req.Description + if req.Description != nil { + policy.Description = *req.Description } if req.AssignmentLogic != "" { if err := validateAssignmentLogic(req.AssignmentLogic); err != nil { @@ -134,6 +172,113 @@ func (s *AgentCapacityPolicyService) Update(ctx context.Context, id, accountID u return policy, nil } +func (s *AgentCapacityPolicyService) CreateInboxCapacityLimit(ctx context.Context, policyID, accountID uint, req CreateInboxCapacityLimitRequest) (*model.InboxCapacityLimit, error) { + policy, err := s.GetByID(ctx, policyID, accountID) + if err != nil { + return nil, err + } + if req.InboxID == 0 { + return nil, fmt.Errorf("inbox_id is required") + } + if req.ConversationLimit < 0 { + return nil, fmt.Errorf("conversation_limit must be greater than or equal to 0") + } + if _, err := s.repo.FindInboxByAccount(ctx, accountID, req.InboxID); err != nil { + return nil, fmt.Errorf("inbox not found: %w", err) + } + if _, err := s.repo.FindInboxCapacityLimitByInbox(ctx, policy.ID, req.InboxID); err == nil { + return nil, fmt.Errorf("inbox has already been assigned to this policy") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("failed to check inbox capacity limit: %w", err) + } + + limit := &model.InboxCapacityLimit{ + AgentCapacityPolicyID: policy.ID, + InboxID: req.InboxID, + ConversationLimit: req.ConversationLimit, + } + if err := s.repo.CreateInboxCapacityLimit(ctx, limit); err != nil { + return nil, fmt.Errorf("failed to create inbox capacity limit: %w", err) + } + return s.repo.FindInboxCapacityLimitByPolicy(ctx, policy.ID, limit.ID) +} + +func (s *AgentCapacityPolicyService) UpdateInboxCapacityLimit(ctx context.Context, policyID, accountID, limitID uint, req UpdateInboxCapacityLimitRequest) (*model.InboxCapacityLimit, error) { + if _, err := s.GetByID(ctx, policyID, accountID); err != nil { + return nil, err + } + if req.ConversationLimit < 0 { + return nil, fmt.Errorf("conversation_limit must be greater than or equal to 0") + } + limit, err := s.repo.FindInboxCapacityLimitByPolicy(ctx, policyID, limitID) + if err != nil { + return nil, fmt.Errorf("inbox capacity limit not found: %w", err) + } + limit.ConversationLimit = req.ConversationLimit + if err := s.repo.UpdateInboxCapacityLimit(ctx, limit); err != nil { + return nil, fmt.Errorf("failed to update inbox capacity limit: %w", err) + } + return s.repo.FindInboxCapacityLimitByPolicy(ctx, policyID, limitID) +} + +func (s *AgentCapacityPolicyService) DeleteInboxCapacityLimit(ctx context.Context, policyID, accountID, limitID uint) error { + if _, err := s.GetByID(ctx, policyID, accountID); err != nil { + return err + } + limit, err := s.repo.FindInboxCapacityLimitByPolicy(ctx, policyID, limitID) + if err != nil { + return fmt.Errorf("inbox capacity limit not found: %w", err) + } + if err := s.repo.DeleteInboxCapacityLimit(ctx, limit); err != nil { + return fmt.Errorf("failed to delete inbox capacity limit: %w", err) + } + return nil +} + +func (s *AgentCapacityPolicyService) ListUsers(ctx context.Context, policyID, accountID uint) ([]model.User, error) { + if _, err := s.GetByID(ctx, policyID, accountID); err != nil { + return nil, err + } + users, err := s.repo.FindPolicyUsers(ctx, accountID, policyID) + if err != nil { + return nil, fmt.Errorf("failed to list agent capacity policy users: %w", err) + } + return users, nil +} + +func (s *AgentCapacityPolicyService) AssignUser(ctx context.Context, policyID, accountID uint, req AssignCapacityPolicyUserRequest) (*model.User, error) { + if _, err := s.GetByID(ctx, policyID, accountID); err != nil { + return nil, err + } + if req.UserID == 0 { + return nil, fmt.Errorf("user_id is required") + } + accountUser, err := s.repo.FindAccountUser(ctx, accountID, req.UserID) + if err != nil { + return nil, fmt.Errorf("account user not found: %w", err) + } + accountUser.AgentCapacityPolicyID = &policyID + if err := s.repo.UpdateAccountUser(ctx, accountUser); err != nil { + return nil, fmt.Errorf("failed to assign user to capacity policy: %w", err) + } + return &accountUser.User, nil +} + +func (s *AgentCapacityPolicyService) RemoveUser(ctx context.Context, policyID, accountID, userID uint) error { + if _, err := s.GetByID(ctx, policyID, accountID); err != nil { + return err + } + accountUser, err := s.repo.FindAccountUser(ctx, accountID, userID) + if err != nil { + return fmt.Errorf("account user not found: %w", err) + } + accountUser.AgentCapacityPolicyID = nil + if err := s.repo.UpdateAccountUser(ctx, accountUser); err != nil { + return fmt.Errorf("failed to remove user from capacity policy: %w", err) + } + return nil +} + // Delete deletes an agent capacity policy scoped to an account. func (s *AgentCapacityPolicyService) Delete(ctx context.Context, id, accountID uint) error { policy, err := s.repo.FindByID(ctx, id) @@ -161,4 +306,4 @@ func (s *AgentCapacityPolicyService) GetByID(ctx context.Context, id, accountID return nil, fmt.Errorf("agent capacity policy not found: policy does not belong to account %d", accountID) } return policy, nil -} \ No newline at end of file +} diff --git a/internal/service/agent_capacity_policy_service_test.go b/internal/service/agent_capacity_policy_service_test.go index bed1197d..50e7cc13 100644 --- a/internal/service/agent_capacity_policy_service_test.go +++ b/internal/service/agent_capacity_policy_service_test.go @@ -27,7 +27,11 @@ func setupACPolicyServiceTest(t *testing.T) (*AgentCapacityPolicyService, *gorm. require.NoError(t, db.AutoMigrate( &model.Account{}, + &model.User{}, + &model.AccountUser{}, + &model.Inbox{}, &model.AgentCapacityPolicy{}, + &model.InboxCapacityLimit{}, ), "failed to auto-migrate") t.Cleanup(func() { @@ -40,6 +44,8 @@ func setupACPolicyServiceTest(t *testing.T) (*AgentCapacityPolicyService, *gorm. return svc, db } +func acStringPtr(value string) *string { return &value } + func createACPSvcTestAccount(t *testing.T, db *gorm.DB) *model.Account { t.Helper() account := &model.Account{Name: "ACPServiceOrg", Locale: "en", Active: true} @@ -90,9 +96,8 @@ func TestAgentCapacityPolicyService_Create(t *testing.T) { account := createACPSvcTestAccount(t, db) policy, err := svc.Create(context.Background(), account.ID, CreateAgentCapacityPolicyRequest{ - Name: "RoundRobinPolicy", - AssignmentLogic: "round_robin", - ExclusionRules: json.RawMessage(`{"exclude_offline":true}`), + Name: "RoundRobinPolicy", + ExclusionRules: json.RawMessage(`{"exclude_offline":true}`), }) assert.NoError(t, err) assert.NotZero(t, policy.ID) @@ -164,7 +169,7 @@ func TestAgentCapacityPolicyService_Update(t *testing.T) { require.NoError(t, err) updated, err := svc.Update(context.Background(), created.ID, account.ID, UpdateAgentCapacityPolicyRequest{ - Name: "NewPolicy", + Name: acStringPtr("NewPolicy"), AssignmentLogic: "least_busy", }) assert.NoError(t, err) @@ -192,11 +197,76 @@ func TestAgentCapacityPolicyService_Update_NotFound(t *testing.T) { svc, _ := setupACPolicyServiceTest(t) _, err := svc.Update(context.Background(), 9999, 1, UpdateAgentCapacityPolicyRequest{ - Name: "Ghost", + Name: acStringPtr("Ghost"), }) assert.Error(t, err) } +func TestAgentCapacityPolicyService_InboxCapacityLimitFlow(t *testing.T) { + svc, db := setupACPolicyServiceTest(t) + account := createACPSvcTestAccount(t, db) + inbox := &model.Inbox{AccountID: account.ID, Name: "Priority", ChannelType: "web_widget"} + require.NoError(t, db.Create(inbox).Error) + otherAccount := createACPSvcTestAccount(t, db) + otherInbox := &model.Inbox{AccountID: otherAccount.ID, Name: "Other", ChannelType: "web_widget"} + require.NoError(t, db.Create(otherInbox).Error) + policy, err := svc.Create(context.Background(), account.ID, CreateAgentCapacityPolicyRequest{Name: "Policy"}) + require.NoError(t, err) + + limit, err := svc.CreateInboxCapacityLimit(context.Background(), policy.ID, account.ID, CreateInboxCapacityLimitRequest{ + InboxID: inbox.ID, + ConversationLimit: 7, + }) + require.NoError(t, err) + assert.Equal(t, inbox.ID, limit.InboxID) + assert.Equal(t, 7, limit.ConversationLimit) + + _, err = svc.CreateInboxCapacityLimit(context.Background(), policy.ID, account.ID, CreateInboxCapacityLimitRequest{InboxID: inbox.ID, ConversationLimit: 9}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already been assigned") + + _, err = svc.CreateInboxCapacityLimit(context.Background(), policy.ID, account.ID, CreateInboxCapacityLimitRequest{InboxID: otherInbox.ID, ConversationLimit: 1}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "inbox not found") + + _, err = svc.UpdateInboxCapacityLimit(context.Background(), policy.ID, account.ID, limit.ID, UpdateInboxCapacityLimitRequest{ConversationLimit: -1}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "greater than or equal") + + updated, err := svc.UpdateInboxCapacityLimit(context.Background(), policy.ID, account.ID, limit.ID, UpdateInboxCapacityLimitRequest{ConversationLimit: 11}) + require.NoError(t, err) + assert.Equal(t, 11, updated.ConversationLimit) + assert.Equal(t, "Priority", updated.Inbox.Name) + + require.NoError(t, svc.DeleteInboxCapacityLimit(context.Background(), policy.ID, account.ID, limit.ID)) + _, err = svc.UpdateInboxCapacityLimit(context.Background(), policy.ID, account.ID, limit.ID, UpdateInboxCapacityLimitRequest{ConversationLimit: 1}) + assert.Error(t, err) +} + +func TestAgentCapacityPolicyService_UserAssignmentFlow(t *testing.T) { + svc, db := setupACPolicyServiceTest(t) + account := createACPSvcTestAccount(t, db) + policy, err := svc.Create(context.Background(), account.ID, CreateAgentCapacityPolicyRequest{Name: "Policy"}) + require.NoError(t, err) + user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent@example.com", Password: "secret", Role: "agent"} + require.NoError(t, db.Create(user).Error) + accountUser := &model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "agent"} + require.NoError(t, db.Create(accountUser).Error) + + assigned, err := svc.AssignUser(context.Background(), policy.ID, account.ID, AssignCapacityPolicyUserRequest{UserID: user.ID}) + require.NoError(t, err) + assert.Equal(t, user.ID, assigned.ID) + + users, err := svc.ListUsers(context.Background(), policy.ID, account.ID) + require.NoError(t, err) + assert.Len(t, users, 1) + + require.NoError(t, svc.RemoveUser(context.Background(), policy.ID, account.ID, user.ID)) + users, err = svc.ListUsers(context.Background(), policy.ID, account.ID) + require.NoError(t, err) + assert.Empty(t, users) +} + // ========== Delete ========== func TestAgentCapacityPolicyService_Delete(t *testing.T) { diff --git a/migrations/000022_add_agent_capacity_policy_links.down.sql b/migrations/000022_add_agent_capacity_policy_links.down.sql new file mode 100644 index 00000000..cd882668 --- /dev/null +++ b/migrations/000022_add_agent_capacity_policy_links.down.sql @@ -0,0 +1,9 @@ +DROP INDEX IF EXISTS idx_inbox_capacity_limits_inbox_id; +DROP INDEX IF EXISTS idx_inbox_capacity_limits_policy_id; +DROP TABLE IF EXISTS inbox_capacity_limits; + +DROP INDEX IF EXISTS idx_agent_capacity_policies_account_id; +DROP TABLE IF EXISTS agent_capacity_policies; + +DROP INDEX IF EXISTS idx_account_users_agent_capacity_policy_id; +ALTER TABLE account_users DROP COLUMN IF EXISTS agent_capacity_policy_id; diff --git a/migrations/000022_add_agent_capacity_policy_links.up.sql b/migrations/000022_add_agent_capacity_policy_links.up.sql new file mode 100644 index 00000000..c5d8151e --- /dev/null +++ b/migrations/000022_add_agent_capacity_policy_links.up.sql @@ -0,0 +1,37 @@ +-- Chatwoot enterprise agent capacity policy links. + +ALTER TABLE account_users ADD COLUMN IF NOT EXISTS agent_capacity_policy_id INTEGER; + +CREATE INDEX IF NOT EXISTS idx_account_users_agent_capacity_policy_id + ON account_users(agent_capacity_policy_id) + WHERE deleted_at IS NULL; + +CREATE TABLE IF NOT EXISTS agent_capacity_policies ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + assignment_logic VARCHAR(50) DEFAULT 'round_robin', + exclusion_rules JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_agent_capacity_policies_account_id + ON agent_capacity_policies(account_id); + +CREATE TABLE IF NOT EXISTS inbox_capacity_limits ( + id SERIAL PRIMARY KEY, + agent_capacity_policy_id INTEGER NOT NULL, + inbox_id INTEGER NOT NULL, + conversation_limit INTEGER NOT NULL CHECK (conversation_limit >= 0), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(agent_capacity_policy_id, inbox_id) +); + +CREATE INDEX IF NOT EXISTS idx_inbox_capacity_limits_policy_id + ON inbox_capacity_limits(agent_capacity_policy_id); + +CREATE INDEX IF NOT EXISTS idx_inbox_capacity_limits_inbox_id + ON inbox_capacity_limits(inbox_id);