Files
gochat/internal/handler/api/v1/macro_handler.go
T

449 lines
13 KiB
Go

package v1
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"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 == "agent" {
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(&macros[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"})
}