package v1 import ( "encoding/json" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/response" ) // MacroHandler handles macro API endpoints. // Reference: Chatwoot MacrosController — CRUD + execute type MacroHandler struct { svc *automation.MacroService auditSvc *service.AuditService } // NewMacroHandler creates a new MacroHandler. func NewMacroHandler(svc *automation.MacroService) *MacroHandler { return &MacroHandler{svc: svc} } func (h *MacroHandler) WithAuditService(auditSvc *service.AuditService) *MacroHandler { h.auditSvc = auditSvc return h } // List retrieves all macros for an account, respecting visibility. // GET /api/v1/accounts/:account_id/macros func (h *MacroHandler) List(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } userID := getUserID(c) macros, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, userID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{ "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.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 } c.JSON(http.StatusOK, gin.H{"payload": serializeMacro(macro)}) } // Create creates a new macro. // POST /api/v1/accounts/:account_id/macros func (h *MacroHandler) Create(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } macro, err := bindMacroRequest(c) if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } macro.AccountID = accountID macro.CreatedByID = getUserID(c) macro.UpdatedByID = getUserID(c) // set_visibility: Chatwoot forces visibility=personal if user role is agent // Reference: Chatwoot macro.rb set_visibility — self.visibility = :personal if user.agent? role := getRole(c) if role == auth.RoleAgent { macro.Visibility = automation.MacroVisibilityPersonal } if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil { if strings.Contains(svcErr.Error(), "invalid attachment") { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"}) return } handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "Macro", AuditableID: macro.ID, Action: "create", AuditedChanges: serializeMacro(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 } 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 } macro.ID = macroID macro.UpdatedByID = getUserID(c) updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro) if svcErr != nil { if strings.Contains(svcErr.Error(), "invalid attachment") { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"}) return } handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "Macro", AuditableID: updated.ID, Action: "update", AuditedChanges: serializeMacro(updated), }) 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 } 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 } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "Macro", AuditableID: macroID, Action: "destroy", AuditedChanges: gin.H{"id": macroID}, }) 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, "macro_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid macro id") return } accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } var body macroExecuteRequest if err := c.ShouldBindJSON(&body); err != nil { 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 } if svcErr := h.svc.ExecuteForDisplayIDs(c.Request.Context(), accountID, macroID, displayIDs, userID); svcErr != nil { handleServiceError(c, svcErr) return } c.Status(http.StatusOK) } // Clone duplicates a macro. // POST /api/v1/accounts/:account_id/macros/:id/clone // Reference: Chatwoot MacrosController does not have clone, but AutomationRulesController does. // gochat adds clone for macros per M6 requirements. func (h *MacroHandler) Clone(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 } _ = accountID // validated but not used — authorization enforced by middleware cloned, svcErr := h.svc.Clone(c.Request.Context(), macroID) if svcErr != nil { handleServiceError(c, svcErr) return } response.Created(c, cloned) } // ToggleActive toggles the active state of a macro. // POST /api/v1/accounts/:account_id/macros/:id/toggle_active // Reference: Chatwoot does not have toggle_active for macros; gochat adds this per M6 requirements. func (h *MacroHandler) ToggleActive(c *gin.Context) { macroID, err := parseUintParam(c, "macro_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } var req struct { Active bool `json:"active"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "active field is required") return } if svcErr := h.svc.ToggleActive(c.Request.Context(), macroID, req.Active); svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{ "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 } var actions automation.Actions if req.Actions != nil { 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) } if len(macro.Files) > 0 { files := make([]gin.H, 0, len(macro.Files)) for _, file := range macro.Files { files = append(files, gin.H{ "id": file.ID, "macro_id": file.MacroID, "file_type": file.FileType, "account_id": file.AccountID, "file_url": file.FileURL, "blob_id": file.BlobID, "filename": file.Filename, }) } item["files"] = files } 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"}) }