feat(assignment-policies): align chatwoot payloads

This commit is contained in:
2026-06-06 10:44:24 +08:00
parent c111926f56
commit 8712358a8f
8 changed files with 551 additions and 206 deletions
@@ -17,6 +17,25 @@ type AssignmentPolicyHandler struct {
svc *service.AssignmentPolicyService
}
// ListAccountPolicies returns all assignment policies for the account.
// GET /api/v1/accounts/:id/assignment_policies
func (h *AssignmentPolicyHandler) ListAccountPolicies(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
return
}
policies, svcErr := h.svc.ListAccountPolicies(c.Request.Context(), accountID)
if svcErr != nil {
applogger.L().Errorf("List assignment policies for account %d: %v", accountID, svcErr)
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, policies)
}
// NewAssignmentPolicyHandler creates a new AssignmentPolicy handler.
func NewAssignmentPolicyHandler(svc *service.AssignmentPolicyService) *AssignmentPolicyHandler {
return &AssignmentPolicyHandler{svc: svc}
@@ -31,14 +50,29 @@ func (h *AssignmentPolicyHandler) GetAccountPolicy(c *gin.Context) {
return
}
policy, svcErr := h.svc.GetAccountPolicy(c.Request.Context(), accountID)
var policyID uint
if raw := c.Param("policy_id"); raw != "" {
id, err := strconv.ParseUint(raw, 10, 32)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid policy ID")
return
}
policyID = uint(id)
}
policy, svcErr := h.svc.GetAccountPolicy(c.Request.Context(), accountID, policyID)
if svcErr != nil {
applogger.L().Errorf("Get assignment policy for account %d: %v", accountID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, policy)
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, payload)
}
// CreateAccountPolicy creates the account-level assignment policy.
@@ -50,20 +84,25 @@ func (h *AssignmentPolicyHandler) CreateAccountPolicy(c *gin.Context) {
return
}
var req service.CreatePolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
req, err := bindAssignmentPolicyCreate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
policy, svcErr := h.svc.CreateAccountPolicy(c.Request.Context(), accountID, req)
policy, svcErr := h.svc.CreateAccountPolicy(c.Request.Context(), accountID, *req)
if svcErr != nil {
applogger.L().Errorf("Create assignment policy for account %d: %v", accountID, svcErr)
handleServiceError(c, svcErr)
return
}
response.Created(c, policy)
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, payload)
}
// UpdateAccountPolicy updates the account-level assignment policy.
@@ -81,20 +120,25 @@ func (h *AssignmentPolicyHandler) UpdateAccountPolicy(c *gin.Context) {
return
}
var req service.UpdatePolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
req, err := bindAssignmentPolicyUpdate(c)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
policy, svcErr := h.svc.UpdateAccountPolicy(c.Request.Context(), uint(id), accountID, req)
policy, svcErr := h.svc.UpdateAccountPolicy(c.Request.Context(), uint(id), accountID, *req)
if svcErr != nil {
applogger.L().Errorf("Update assignment policy %d for account %d: %v", id, accountID, svcErr)
handleServiceError(c, svcErr)
return
}
response.OK(c, policy)
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, payload)
}
// DeleteAccountPolicy deletes the account-level assignment policy.
@@ -118,7 +162,7 @@ func (h *AssignmentPolicyHandler) DeleteAccountPolicy(c *gin.Context) {
return
}
response.NoContent(c)
c.Status(http.StatusOK)
}
// GetInboxPolicy returns the inbox-level assignment policy override.
@@ -143,7 +187,86 @@ func (h *AssignmentPolicyHandler) GetInboxPolicy(c *gin.Context) {
return
}
response.OK(c, policy)
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, payload)
}
// ListPolicyInboxes returns Chatwoot `{ inboxes: [...] }` payload for a policy.
// GET /api/v1/accounts/:id/assignment_policies/:policy_id/inboxes
func (h *AssignmentPolicyHandler) ListPolicyInboxes(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
return
}
policyID, err := strconv.ParseUint(c.Param("policy_id"), 10, 32)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid policy ID")
return
}
inboxes, svcErr := h.svc.ListPolicyInboxes(c.Request.Context(), accountID, uint(policyID))
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, gin.H{"inboxes": inboxes})
}
// AddPolicyInbox associates an inbox with a policy through the nested policy route.
// POST /api/v1/accounts/:id/assignment_policies/:policy_id/inboxes
func (h *AssignmentPolicyHandler) AddPolicyInbox(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
return
}
policyID, err := strconv.ParseUint(c.Param("policy_id"), 10, 32)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid policy ID")
return
}
var req service.CreateInboxPolicyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
req.AssignmentPolicyID = uint(policyID)
policy, svcErr := h.svc.CreateInboxPolicy(c.Request.Context(), accountID, req)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, payload)
}
// RemovePolicyInbox removes the current policy association from an inbox.
// DELETE /api/v1/accounts/:id/assignment_policies/:policy_id/inboxes/:inbox_id
func (h *AssignmentPolicyHandler) RemovePolicyInbox(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
return
}
inboxID, err := strconv.ParseUint(c.Param("inbox_id"), 10, 32)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid inbox ID")
return
}
if svcErr := h.svc.DeleteInboxPolicy(c.Request.Context(), uint(inboxID), accountID); svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.Status(http.StatusOK)
}
// CreateInboxPolicy creates an inbox-level assignment policy override.
@@ -163,7 +286,7 @@ func (h *AssignmentPolicyHandler) CreateInboxPolicy(c *gin.Context) {
var req service.CreateInboxPolicyRequest
req.InboxID = uint(inboxID) // Set from URL param
if err := c.ShouldBindJSON(&req); err != nil {
if err := bindAssignmentPolicyBody(c, &req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
return
}
@@ -175,7 +298,12 @@ func (h *AssignmentPolicyHandler) CreateInboxPolicy(c *gin.Context) {
return
}
response.Created(c, policy)
payload, svcErr := h.svc.SerializePolicy(c.Request.Context(), policy)
if svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.JSON(http.StatusOK, payload)
}
// UpdateInboxPolicy updates an inbox-level assignment policy override.
@@ -230,5 +358,52 @@ func (h *AssignmentPolicyHandler) DeleteInboxPolicy(c *gin.Context) {
return
}
response.NoContent(c)
c.Status(http.StatusOK)
}
// DeleteCurrentInboxPolicy removes the policy association for the URL inbox.
// DELETE /api/v1/accounts/:id/inboxes/:inbox_id/assignment_policy
func (h *AssignmentPolicyHandler) DeleteCurrentInboxPolicy(c *gin.Context) {
accountID := getAccountID(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "account not identified")
return
}
inboxID, err := strconv.ParseUint(c.Param("inbox_id"), 10, 32)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid inbox ID")
return
}
if svcErr := h.svc.DeleteInboxPolicy(c.Request.Context(), uint(inboxID), accountID); svcErr != nil {
handleServiceError(c, svcErr)
return
}
c.Status(http.StatusOK)
}
func bindAssignmentPolicyCreate(c *gin.Context) (*service.CreatePolicyRequest, error) {
var req service.CreatePolicyRequest
if err := bindChatwootPayload(c, "assignment_policy", &req); err != nil {
return nil, err
}
return &req, nil
}
func bindAssignmentPolicyUpdate(c *gin.Context) (*service.UpdatePolicyRequest, error) {
var req service.UpdatePolicyRequest
if err := bindChatwootPayload(c, "assignment_policy", &req); err != nil {
return nil, err
}
return &req, nil
}
func bindAssignmentPolicyBody(c *gin.Context, req *service.CreateInboxPolicyRequest) error {
var body struct {
AssignmentPolicyID uint `json:"assignment_policy_id"`
}
if err := c.ShouldBindJSON(&body); err != nil {
return err
}
req.AssignmentPolicyID = body.AssignmentPolicyID
return nil
}
@@ -40,8 +40,9 @@ func (s *AssignmentPolicyHandlerTestSuite) SetupSuite() {
// AutoMigrate all required models
err = db.AutoMigrate(
&model.Account{},
&autoassignment.AssignmentPolicy{},
&autoassignment.InboxAssignmentPolicy{},
&model.Inbox{},
&model.AssignmentPolicy{},
&model.InboxAssignmentPolicy{},
)
s.Require().NoError(err)
@@ -76,6 +77,14 @@ func (s *AssignmentPolicyHandlerTestSuite) SetupSuite() {
rg.POST("/assignment_policy", s.handler.CreateAccountPolicy)
rg.PUT("/assignment_policy/:policy_id", s.handler.UpdateAccountPolicy)
rg.DELETE("/assignment_policy/:policy_id", s.handler.DeleteAccountPolicy)
rg.GET("/assignment_policies", s.handler.ListAccountPolicies)
rg.POST("/assignment_policies", s.handler.CreateAccountPolicy)
rg.GET("/assignment_policies/:policy_id", s.handler.GetAccountPolicy)
rg.PATCH("/assignment_policies/:policy_id", s.handler.UpdateAccountPolicy)
rg.GET("/assignment_policies/:policy_id/inboxes", s.handler.ListPolicyInboxes)
rg.POST("/inboxes/:inbox_id/assignment_policy", s.handler.CreateInboxPolicy)
rg.GET("/inboxes/:inbox_id/assignment_policy", s.handler.GetInboxPolicy)
rg.DELETE("/inboxes/:inbox_id/assignment_policy", s.handler.DeleteCurrentInboxPolicy)
}
}
@@ -106,12 +115,14 @@ func TestAssignmentPolicyHandlerTestSuite(t *testing.T) {
func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Success() {
// Create a policy via the service (since the POST endpoint also tests creation, use DB directly for setup)
policy := &autoassignment.AssignmentPolicy{
policy := &model.AssignmentPolicy{
AccountID: 1,
Policy: autoassignment.PolicyRoundRobin,
Name: "Default",
AssignmentOrder: 0,
ConversationPriority: 1,
FairDistributionLimit: 5,
FairDistributionWindow: 300,
Active: true,
Enabled: true,
}
s.Require().NoError(s.db.Create(policy).Error)
@@ -123,11 +134,9 @@ func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Success() {
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp["success"].(bool))
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), float64(1), data["account_id"])
assert.Equal(s.T(), "round_robin", data["policy"])
assert.Equal(s.T(), "Default", resp["name"])
assert.Equal(s.T(), float64(1), resp["conversation_priority"])
assert.Equal(s.T(), true, resp["enabled"])
}
func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_NotFound() {
@@ -163,23 +172,19 @@ func (s *AssignmentPolicyHandlerTestSuite) TestGetAccountPolicy_Unauthorized() {
// ==================== CreateAccountPolicy ====================
func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Success() {
body := `{"policy":"round_robin","fair_distribution_limit":5,"fair_distribution_window":300,"active":true}`
body := `{"name":"Default","assignment_order":0,"conversation_priority":1,"fair_distribution_limit":5,"fair_distribution_window":300,"enabled":true}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/assignment_policy", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusCreated, w.Code)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp["success"].(bool))
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), float64(1), data["account_id"])
assert.Equal(s.T(), "round_robin", data["policy"])
assert.Equal(s.T(), float64(5), data["fair_distribution_limit"])
assert.Equal(s.T(), float64(300), data["fair_distribution_window"])
assert.Equal(s.T(), "Default", resp["name"])
assert.Equal(s.T(), float64(5), resp["fair_distribution_limit"])
assert.Equal(s.T(), float64(300), resp["fair_distribution_window"])
}
func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_InvalidJSON() {
@@ -218,7 +223,7 @@ func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Unauthorized(
unauthRouter := gin.New()
unauthRouter.POST("/api/v1/accounts/:account_id/assignment_policy", s.handler.CreateAccountPolicy)
body := `{"policy":"round_robin"}`
body := `{"name":"Default"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/assignment_policy", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
@@ -231,16 +236,16 @@ func (s *AssignmentPolicyHandlerTestSuite) TestCreateAccountPolicy_Unauthorized(
func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() {
// Create a policy first via DB
policy := &autoassignment.AssignmentPolicy{
policy := &model.AssignmentPolicy{
AccountID: 1,
Policy: autoassignment.PolicyRoundRobin,
Name: "Default",
FairDistributionLimit: 5,
FairDistributionWindow: 300,
Active: true,
Enabled: true,
}
s.Require().NoError(s.db.Create(policy).Error)
body := `{"policy":"longest_waiting","fair_distribution_limit":10}`
body := `{"name":"Priority","fair_distribution_limit":10}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/"+uintToStr(policy.ID), bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
@@ -250,15 +255,12 @@ func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_Success() {
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(s.T(), resp["success"].(bool))
data := resp["data"].(map[string]interface{})
assert.Equal(s.T(), "longest_waiting", data["policy"])
assert.Equal(s.T(), float64(10), data["fair_distribution_limit"])
assert.Equal(s.T(), "Priority", resp["name"])
assert.Equal(s.T(), float64(10), resp["fair_distribution_limit"])
}
func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_NotFound() {
body := `{"policy":"round_robin"}`
body := `{"name":"Default"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("PUT", "/api/v1/accounts/1/assignment_policy/9999", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
@@ -281,12 +283,12 @@ func (s *AssignmentPolicyHandlerTestSuite) TestUpdateAccountPolicy_InvalidJSON()
// ==================== DeleteAccountPolicy ====================
func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_Success() {
policy := &autoassignment.AssignmentPolicy{
policy := &model.AssignmentPolicy{
AccountID: 1,
Policy: autoassignment.PolicyRoundRobin,
Name: "Default",
FairDistributionLimit: 5,
FairDistributionWindow: 300,
Active: true,
Enabled: true,
}
s.Require().NoError(s.db.Create(policy).Error)
@@ -294,7 +296,58 @@ func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_Success() {
req, _ := http.NewRequest("DELETE", "/api/v1/accounts/1/assignment_policy/"+uintToStr(policy.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
assert.Equal(s.T(), http.StatusOK, w.Code)
}
func (s *AssignmentPolicyHandlerTestSuite) TestPluralAssignmentPolicies_ChatwootPayloads() {
policy := &model.AssignmentPolicy{AccountID: 1, Name: "Balanced", Description: "Route VIP first", AssignmentOrder: 1, ConversationPriority: 1, FairDistributionLimit: 20, FairDistributionWindow: 1800, Enabled: true}
s.Require().NoError(s.db.Create(policy).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/assignment_policies", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var list []map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &list))
s.Require().Len(list, 1)
assert.Equal(s.T(), "Balanced", list[0]["name"])
assert.Contains(s.T(), list[0], "assigned_inbox_count")
}
func (s *AssignmentPolicyHandlerTestSuite) TestInboxAssignmentPolicy_ChatwootRoutes() {
policy := &model.AssignmentPolicy{AccountID: 1, Name: "Default", Enabled: true}
s.Require().NoError(s.db.Create(policy).Error)
inbox := &model.Inbox{AccountID: 1, Name: "Support", ChannelType: "web_widget"}
s.Require().NoError(s.db.Create(inbox).Error)
body := `{"assignment_policy_id":` + uintToStr(policy.ID) + `}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var show map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &show))
assert.Equal(s.T(), "Default", show["name"])
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/accounts/1/assignment_policies/"+uintToStr(policy.ID)+"/inboxes", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var inboxes map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &inboxes))
s.Require().Len(inboxes["inboxes"], 1)
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/api/v1/accounts/1/inboxes/"+uintToStr(inbox.ID)+"/assignment_policy", nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
}
func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_NotFound() {
@@ -317,4 +370,4 @@ func (s *AssignmentPolicyHandlerTestSuite) TestDeleteAccountPolicy_NotFound() {
// uintToStr converts a uint to string for URL path construction.
func uintToStr(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}
}