feat(macros): align chatwoot macro payloads
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gochat/gochat/internal/automation"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
)
|
||||
|
||||
@@ -38,26 +40,35 @@ func (h *MacroHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"payload": macros,
|
||||
"payload": serializeMacros(macros),
|
||||
})
|
||||
}
|
||||
|
||||
// Get retrieves a single macro by ID.
|
||||
// GET /api/v1/accounts/:account_id/macros/:id
|
||||
func (h *MacroHandler) Get(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
macroID, err := parseUintParam(c, "macro_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
macro, svcErr := h.svc.GetByID(c.Request.Context(), macroID)
|
||||
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !h.svc.CanAccess(macro, getUserID(c), getRole(c), "show") {
|
||||
abortMacroUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, macro)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)})
|
||||
}
|
||||
|
||||
// Create creates a new macro.
|
||||
@@ -69,8 +80,8 @@ func (h *MacroHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var macro automation.Macro
|
||||
if err := c.ShouldBindJSON(¯o); err != nil {
|
||||
macro, err := bindMacroRequest(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -86,25 +97,40 @@ func (h *MacroHandler) Create(c *gin.Context) {
|
||||
macro.Visibility = automation.MacroVisibilityPersonal
|
||||
}
|
||||
|
||||
if svcErr := h.svc.Create(c.Request.Context(), ¯o); svcErr != nil {
|
||||
if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.Created(c, macro)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)})
|
||||
}
|
||||
|
||||
// Update updates an existing macro.
|
||||
// PUT /api/v1/accounts/:account_id/macros/:id
|
||||
func (h *MacroHandler) Update(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
macroID, err := parseUintParam(c, "macro_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
var macro automation.Macro
|
||||
if err := c.ShouldBindJSON(¯o); err != nil {
|
||||
existing, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !h.svc.CanAccess(existing, getUserID(c), getRole(c), "update") {
|
||||
abortMacroUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
macro, err := bindMacroRequest(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -112,39 +138,52 @@ func (h *MacroHandler) Update(c *gin.Context) {
|
||||
macro.ID = macroID
|
||||
macro.UpdatedByID = getUserID(c)
|
||||
|
||||
if svcErr := h.svc.Update(c.Request.Context(), ¯o); svcErr != nil {
|
||||
updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, macro)
|
||||
c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(updated)})
|
||||
}
|
||||
|
||||
// Delete soft-deletes a macro.
|
||||
// DELETE /api/v1/accounts/:account_id/macros/:id
|
||||
func (h *MacroHandler) Delete(c *gin.Context) {
|
||||
accountID, err := parseUintParam(c, "account_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
|
||||
return
|
||||
}
|
||||
macroID, err := parseUintParam(c, "macro_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
if svcErr := h.svc.Delete(c.Request.Context(), macroID); svcErr != nil {
|
||||
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !h.svc.CanAccess(macro, getUserID(c), getRole(c), "destroy") {
|
||||
abortMacroUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
if svcErr := h.svc.DeleteForAccount(c.Request.Context(), accountID, macroID); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strconv.FormatUint(uint64(macroID), 10),
|
||||
"deleted": true,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Execute runs a macro on a conversation.
|
||||
// POST /api/v1/accounts/:account_id/macros/:id/execute
|
||||
// Reference: Chatwoot MacrosController#execute — MacroService.Execute returns error only
|
||||
func (h *MacroHandler) Execute(c *gin.Context) {
|
||||
macroID, err := parseUintParam(c, "id")
|
||||
macroID, err := parseUintParam(c, "macro_id")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid macro id")
|
||||
return
|
||||
@@ -156,26 +195,30 @@ func (h *MacroHandler) Execute(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ConversationID uint `json:"conversation_id"`
|
||||
}
|
||||
var body macroExecuteRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_id is required")
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_ids is required")
|
||||
return
|
||||
}
|
||||
displayIDs := body.DisplayIDs()
|
||||
|
||||
userID := getUserID(c)
|
||||
macro, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, macroID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
if !h.svc.CanAccess(macro, userID, getRole(c), "execute") {
|
||||
abortMacroUnauthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
|
||||
if svcErr := h.svc.Execute(c.Request.Context(), accountID, body.ConversationID, macroID, userID); svcErr != nil {
|
||||
if svcErr := h.svc.ExecuteForDisplayIDs(c.Request.Context(), accountID, macroID, displayIDs, userID); svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"macro_id": strconv.FormatUint(uint64(macroID), 10),
|
||||
"conversation_id": strconv.FormatUint(uint64(body.ConversationID), 10),
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Clone duplicates a macro.
|
||||
@@ -230,7 +273,122 @@ func (h *MacroHandler) ToggleActive(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": strconv.FormatUint(uint64(macroID), 10),
|
||||
"id": macroID,
|
||||
"active": req.Active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type macroActionRequest struct {
|
||||
ActionName string `json:"action_name"`
|
||||
ActionParams json.RawMessage `json:"action_params"`
|
||||
}
|
||||
|
||||
type macroRequest struct {
|
||||
Name string `json:"name"`
|
||||
Visibility interface{} `json:"visibility"`
|
||||
Actions []macroActionRequest `json:"actions"`
|
||||
}
|
||||
|
||||
func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
|
||||
var req macroRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions := make(automation.Actions, 0, len(req.Actions))
|
||||
for _, actionReq := range req.Actions {
|
||||
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
|
||||
}
|
||||
return &automation.Macro{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Visibility: parseMacroVisibility(req.Visibility),
|
||||
Actions: actions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseMacroVisibility(value interface{}) automation.MacroVisibility {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if strings.EqualFold(v, "global") {
|
||||
return automation.MacroVisibilityGlobal
|
||||
}
|
||||
case float64:
|
||||
if int(v) == int(automation.MacroVisibilityGlobal) {
|
||||
return automation.MacroVisibilityGlobal
|
||||
}
|
||||
case int:
|
||||
if v == int(automation.MacroVisibilityGlobal) {
|
||||
return automation.MacroVisibilityGlobal
|
||||
}
|
||||
}
|
||||
return automation.MacroVisibilityPersonal
|
||||
}
|
||||
|
||||
type macroExecuteRequest struct {
|
||||
ConversationIDs []uint `json:"conversation_ids"`
|
||||
ConversationID uint `json:"conversation_id"`
|
||||
}
|
||||
|
||||
func (r macroExecuteRequest) DisplayIDs() []uint {
|
||||
if len(r.ConversationIDs) > 0 {
|
||||
return r.ConversationIDs
|
||||
}
|
||||
if r.ConversationID > 0 {
|
||||
return []uint{r.ConversationID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializeMacros(macros []automation.Macro) []gin.H {
|
||||
result := make([]gin.H, 0, len(macros))
|
||||
for i := range macros {
|
||||
result = append(result, serializeMacro(¯os[i]))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func serializeMacro(macro *automation.Macro) gin.H {
|
||||
item := gin.H{
|
||||
"id": macro.ID,
|
||||
"name": macro.Name,
|
||||
"visibility": macroVisibilityString(macro.Visibility),
|
||||
"account_id": macro.AccountID,
|
||||
"actions": serializeAutomationActions(macro.Actions),
|
||||
}
|
||||
if macro.CreatedBy != nil && macro.CreatedBy.ID != 0 {
|
||||
item["created_by"] = serializeMacroAgent(macro.CreatedBy)
|
||||
}
|
||||
if macro.UpdatedBy != nil && macro.UpdatedBy.ID != 0 {
|
||||
item["updated_by"] = serializeMacroAgent(macro.UpdatedBy)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func macroVisibilityString(visibility automation.MacroVisibility) string {
|
||||
if visibility == automation.MacroVisibilityGlobal {
|
||||
return "global"
|
||||
}
|
||||
return "personal"
|
||||
}
|
||||
|
||||
func serializeMacroAgent(user *model.User) gin.H {
|
||||
availability := "offline"
|
||||
if user.Available {
|
||||
availability = "online"
|
||||
}
|
||||
return gin.H{
|
||||
"id": user.ID,
|
||||
"name": user.Name,
|
||||
"available_name": user.Name,
|
||||
"email": user.Email,
|
||||
"availability_status": availability,
|
||||
"thumbnail": user.AvatarURL,
|
||||
}
|
||||
}
|
||||
|
||||
func abortMacroUnauthorized(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "You are not authorized to do this action"})
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &automation.Macro{}))
|
||||
s.Require().NoError(db.AutoMigrate(
|
||||
&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{},
|
||||
&automation.Macro{}, &automation.MacroExecution{}, &automation.ConversationLabel{}, &automation.ConversationMute{},
|
||||
))
|
||||
s.db = db
|
||||
|
||||
macroSvc := automation.NewMacroService(&testMacroDBProvider{db: db})
|
||||
@@ -82,8 +85,8 @@ func (s *MacroHandlerTestSuite) TestCreate_Success() {
|
||||
|
||||
body := map[string]interface{}{
|
||||
"name": "test-macro",
|
||||
"visibility": 0,
|
||||
"actions": []map[string]interface{}{{"action_name": "assign_agent", "action_params": map[string]interface{}{"agent_id": "1"}}},
|
||||
"visibility": "global",
|
||||
"actions": []map[string]interface{}{{"action_name": "assign_agent", "action_params": []interface{}{"self"}}},
|
||||
}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
@@ -92,7 +95,17 @@ func (s *MacroHandlerTestSuite) TestCreate_Success() {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusCreated, w.Code)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "test-macro", payload["name"])
|
||||
assert.Equal(s.T(), "global", payload["visibility"])
|
||||
createdBy := payload["created_by"].(map[string]interface{})
|
||||
assert.Equal(s.T(), float64(s.user.ID), createdBy["id"])
|
||||
actions := payload["actions"].([]interface{})
|
||||
action := actions[0].(map[string]interface{})
|
||||
assert.Equal(s.T(), []interface{}{"self"}, action["action_params"])
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestCreate_BadRequest() {
|
||||
@@ -105,4 +118,107 @@ func (s *MacroHandlerTestSuite) TestCreate_BadRequest() {
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestUpdateDeleteAuthorizationAndPayload() {
|
||||
admin := &model.User{Name: "Admin", Email: "macro-admin@example.com", Role: "administrator"}
|
||||
s.Require().NoError(s.db.Create(admin).Error)
|
||||
agent := &model.User{Name: "Agent", Email: "macro-agent@example.com", Role: "agent"}
|
||||
s.Require().NoError(s.db.Create(agent).Error)
|
||||
macro := &automation.Macro{
|
||||
AccountID: s.account.ID,
|
||||
Name: "public macro",
|
||||
Actions: automation.Actions{},
|
||||
Visibility: automation.MacroVisibilityGlobal,
|
||||
Active: true,
|
||||
CreatedByID: admin.ID,
|
||||
UpdatedByID: admin.ID,
|
||||
}
|
||||
s.Require().NoError(s.db.Create(macro).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.PUT("/api/v1/accounts/:account_id/macros/:macro_id", func(c *gin.Context) {
|
||||
c.Set("user_id", agent.ID)
|
||||
c.Set("role", "agent")
|
||||
c.Next()
|
||||
}, s.handler.Update)
|
||||
|
||||
body := []byte(`{"name":"agent edit","visibility":"global","actions":[]}`)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/macros/%d", s.account.ID, macro.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, w.Code)
|
||||
|
||||
r = gin.New()
|
||||
r.PUT("/api/v1/accounts/:account_id/macros/:macro_id", func(c *gin.Context) {
|
||||
c.Set("user_id", admin.ID)
|
||||
c.Set("role", "administrator")
|
||||
c.Next()
|
||||
}, s.handler.Update)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", fmt.Sprintf("/api/v1/accounts/%d/macros/%d", s.account.ID, macro.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
assert.Equal(s.T(), "agent edit", payload["name"])
|
||||
assert.Equal(s.T(), "global", payload["visibility"])
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestExecute_UsesConversationDisplayIDsAndMutatesConversation() {
|
||||
admin := &model.User{Name: "Exec Admin", Email: "macro-exec-admin@example.com", Role: "administrator"}
|
||||
s.Require().NoError(s.db.Create(admin).Error)
|
||||
inbox := &model.Inbox{AccountID: s.account.ID, Name: "Macro Inbox", ChannelType: "web"}
|
||||
s.Require().NoError(s.db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: s.account.ID, Name: "Macro Contact", Email: "macro-contact@example.com"}
|
||||
s.Require().NoError(s.db.Create(contact).Error)
|
||||
displayID := uint(444)
|
||||
conversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", Priority: "low", ChannelType: "web", Channel: "web_widget"}
|
||||
s.Require().NoError(s.db.Create(conversation).Error)
|
||||
macro := &automation.Macro{
|
||||
AccountID: s.account.ID,
|
||||
Name: "execute macro",
|
||||
Actions: automation.Actions{
|
||||
{ActionName: "add_label", ActionParams: map[string]interface{}{"labels": []string{"vip"}}},
|
||||
{ActionName: "change_status", ActionParams: map[string]interface{}{"status": "resolved"}},
|
||||
{ActionName: "send_message", ActionParams: map[string]interface{}{"content": "Hello from macro"}},
|
||||
{ActionName: "add_private_note", ActionParams: map[string]interface{}{"content": "Internal macro note"}},
|
||||
},
|
||||
Visibility: automation.MacroVisibilityGlobal,
|
||||
Active: true,
|
||||
CreatedByID: admin.ID,
|
||||
UpdatedByID: admin.ID,
|
||||
}
|
||||
s.Require().NoError(s.db.Create(macro).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/macros/:macro_id/execute", func(c *gin.Context) {
|
||||
c.Set("user_id", admin.ID)
|
||||
c.Set("role", "administrator")
|
||||
c.Next()
|
||||
}, s.handler.Execute)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros/%d/execute", s.account.ID, macro.ID), bytes.NewBufferString(`{"conversation_ids":[444]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
|
||||
var reloaded model.Conversation
|
||||
s.Require().NoError(s.db.First(&reloaded, conversation.ID).Error)
|
||||
assert.Equal(s.T(), "resolved", reloaded.Status)
|
||||
var label automation.ConversationLabel
|
||||
s.Require().NoError(s.db.Where("conversation_id = ? AND label = ?", conversation.ID, "vip").First(&label).Error)
|
||||
var messages []model.Message
|
||||
s.Require().NoError(s.db.Where("conversation_id = ?", conversation.ID).Order("id ASC").Find(&messages).Error)
|
||||
s.Require().Len(messages, 2)
|
||||
assert.Equal(s.T(), "Hello from macro", messages[0].Content)
|
||||
assert.Equal(s.T(), admin.ID, *messages[0].SenderID)
|
||||
assert.False(s.T(), messages[0].Private)
|
||||
assert.Equal(s.T(), "Internal macro note", messages[1].Content)
|
||||
assert.True(s.T(), messages[1].Private)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user