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
+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)
}