feat(capacity): align chatwoot inbox capacity limits

This commit is contained in:
2026-06-05 06:03:44 +08:00
parent 492d916bef
commit b197e54d9f
12 changed files with 783 additions and 64 deletions
+12
View File
@@ -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"},
+9 -1
View File
@@ -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
+13 -2
View File
@@ -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 |
+286 -18
View File
@@ -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)
}
}
}
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)
}
@@ -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)
}
}
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)
}
+17 -15
View File
@@ -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"
}
}
+9 -8
View File
@@ -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" }
func (AgentCapacityPolicy) TableName() string { return "agent_capacity_policies" }
@@ -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
}
}
+155 -10
View File
@@ -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
}
}
@@ -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) {
@@ -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;
@@ -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);