package v1 import ( "encoding/json" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/automation" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/pkg/response" ) // AutomationRuleHandler handles automation rule API endpoints. // Reference: Chatwoot AutomationRulesController — CRUD + clone type AutomationRuleHandler struct { svc *automation.AutomationRuleService auditSvc *service.AuditService } // NewAutomationRuleHandler creates a new AutomationRuleHandler. func NewAutomationRuleHandler(svc *automation.AutomationRuleService) *AutomationRuleHandler { return &AutomationRuleHandler{svc: svc} } func (h *AutomationRuleHandler) WithAuditService(auditSvc *service.AuditService) *AutomationRuleHandler { h.auditSvc = auditSvc return h } type automationRuleRequest struct { Name string `json:"name"` Description string `json:"description"` EventName string `json:"event_name"` Active *bool `json:"active"` Conditions []automationRuleConditionRequest `json:"conditions"` Actions *[]automationRuleActionRequest `json:"actions"` } type automationRuleConditionRequest struct { AttributeKey string `json:"attribute_key"` Attribute string `json:"attribute"` FilterOperator string `json:"filter_operator"` Values []interface{} `json:"values"` QueryOperator string `json:"query_operator"` CustomAttributeType string `json:"custom_attribute_type"` } type automationRuleActionRequest struct { ActionName string `json:"action_name"` ActionParams json.RawMessage `json:"action_params"` } // List retrieves all automation rules for an account. // GET /api/v1/accounts/:account_id/automation_rules func (h *AutomationRuleHandler) List(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } rules, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{ "payload": serializeAutomationRules(rules), }) } // Get retrieves a single automation rule by ID. // GET /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Get(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } rule, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, automationID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(rule)}) } // Create creates a new automation rule. // POST /api/v1/accounts/:account_id/automation_rules func (h *AutomationRuleHandler) Create(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } var req automationRuleRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } if req.Name == "" || req.EventName == "" { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "name and event_name are required") return } rule, bindErr := buildAutomationRuleFromRequest(accountID, req) if bindErr != nil { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, bindErr.Error()) return } if svcErr := h.svc.Create(c.Request.Context(), rule); 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: "AutomationRule", AuditableID: rule.ID, Action: "create", AuditedChanges: serializeAutomationRule(rule), }) c.JSON(http.StatusOK, serializeAutomationRule(rule)) } // Update updates an existing automation rule. // PUT /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Update(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } var req automationRuleRequest if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } if req.Name == "" || req.EventName == "" { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, "name and event_name are required") return } rule, bindErr := buildAutomationRuleFromRequest(accountID, req) if bindErr != nil { response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, bindErr.Error()) return } rule.ID = automationID if svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, rule); svcErr != nil { if strings.Contains(svcErr.Error(), "invalid attachment") { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"}) return } handleServiceError(c, svcErr) return } updated, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, automationID) if svcErr != nil { handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "AutomationRule", AuditableID: updated.ID, Action: "update", AuditedChanges: serializeAutomationRule(updated), }) c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(updated)}) } // Delete soft-deletes an automation rule. // DELETE /api/v1/accounts/:account_id/automation_rules/:id func (h *AutomationRuleHandler) Delete(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } if svcErr := h.svc.DeleteForAccount(c.Request.Context(), accountID, automationID); svcErr != nil { handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "AutomationRule", AuditableID: automationID, Action: "destroy", AuditedChanges: gin.H{"id": automationID}, }) c.Status(http.StatusOK) } // Clone duplicates an automation rule. // POST /api/v1/accounts/:account_id/automation_rules/:id/clone func (h *AutomationRuleHandler) Clone(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } automationID, err := parseUintParam(c, "automation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id") return } cloned, svcErr := h.svc.CloneForAccount(c.Request.Context(), accountID, automationID) if svcErr != nil { handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "AutomationRule", AuditableID: cloned.ID, Action: "create", AuditedChanges: serializeAutomationRule(cloned), Comment: "cloned from automation rule", }) c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(cloned)}) } // ToggleActive toggles the active state of an automation rule. // POST /api/v1/accounts/:account_id/automation_rules/:id/toggle_active // Reference: Chatwoot does not have a toggle_active endpoint; gochat adds this per M6 requirements. func (h *AutomationRuleHandler) ToggleActive(c *gin.Context) { accountID, err := parseUintParam(c, "account_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id") return } automationID, err := parseUintParam(c, "automation_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 req.Active == nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "active field is required") return } if svcErr := h.svc.ToggleActiveForAccount(c.Request.Context(), accountID, automationID, *req.Active); svcErr != nil { handleServiceError(c, svcErr) return } rule, svcErr := h.svc.GetByIDForAccount(c.Request.Context(), accountID, automationID) if svcErr != nil { handleServiceError(c, svcErr) return } recordAuditMutation(c, h.auditSvc, auditMutation{ AccountID: accountID, AuditableType: "AutomationRule", AuditableID: rule.ID, Action: "update", AuditedChanges: gin.H{"active": rule.Active}, }) c.JSON(http.StatusOK, gin.H{"payload": serializeAutomationRule(rule)}) } func buildAutomationRuleFromRequest(accountID uint, req automationRuleRequest) (*automation.AutomationRule, error) { active := true if req.Active != nil { active = *req.Active } conditions := make(automation.Conditions, 0, len(req.Conditions)) for _, condition := range req.Conditions { attribute := condition.Attribute if attribute == "" { attribute = condition.AttributeKey } values := make([]string, 0, len(condition.Values)) for _, value := range condition.Values { if value == nil { continue } values = append(values, valueToString(value)) } conditions = append(conditions, automation.Condition{ Attribute: attribute, AttributeKey: attribute, FilterOperator: automation.NormalizeFilterOperator(condition.FilterOperator), Values: values, QueryOperator: condition.QueryOperator, CustomAttributeType: condition.CustomAttributeType, }) } 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.AutomationRule{ AccountID: accountID, Name: req.Name, Description: req.Description, EventName: req.EventName, Conditions: automation.NormalizeConditions(conditions), Actions: actions, Active: active, }, nil } func serializeAutomationRules(rules []automation.AutomationRule) []gin.H { result := make([]gin.H, 0, len(rules)) for i := range rules { result = append(result, serializeAutomationRule(&rules[i])) } return result } func serializeAutomationRule(rule *automation.AutomationRule) gin.H { serialized := gin.H{ "id": rule.ID, "account_id": rule.AccountID, "name": rule.Name, "description": rule.Description, "event_name": rule.EventName, "conditions": serializeAutomationConditions(rule.Conditions), "actions": serializeAutomationActions(rule.Actions), "created_on": rule.CreatedAt.Unix(), "active": rule.Active, } if len(rule.Files) > 0 { serialized["files"] = serializeAutomationRuleFiles(rule.Files) } return serialized } func serializeAutomationRuleFiles(files []automation.AutomationRuleFile) []gin.H { result := make([]gin.H, 0, len(files)) for _, file := range files { result = append(result, gin.H{ "id": file.ID, "automation_rule_id": file.AutomationRuleID, "file_type": file.FileType, "account_id": file.AccountID, "file_url": file.FileURL, "blob_id": file.BlobID, "filename": file.Filename, }) } return result } func serializeAutomationConditions(conditions automation.Conditions) []gin.H { conditions = automation.NormalizeConditions(conditions) result := make([]gin.H, 0, len(conditions)) for _, condition := range conditions { item := gin.H{ "attribute_key": condition.Attribute, "filter_operator": automation.ChatwootFilterOperator(condition.FilterOperator), "values": condition.Values, } if condition.QueryOperator != "" { item["query_operator"] = condition.QueryOperator } if condition.CustomAttributeType != "" { item["custom_attribute_type"] = condition.CustomAttributeType } result = append(result, item) } return result } func serializeAutomationActions(actions automation.Actions) []gin.H { result := make([]gin.H, 0, len(actions)) for _, action := range actions { result = append(result, gin.H{ "action_name": action.ActionName, "action_params": chatwootActionParams(action), }) } return result } func normalizeAutomationActionParams(actionName string, raw json.RawMessage) (map[string]interface{}, error) { if len(raw) == 0 || string(raw) == "null" { return map[string]interface{}{}, nil } var object map[string]interface{} if err := json.Unmarshal(raw, &object); err == nil { if object == nil { object = map[string]interface{}{} } return object, nil } var values []interface{} if err := json.Unmarshal(raw, &values); err != nil { var single interface{} if singleErr := json.Unmarshal(raw, &single); singleErr != nil { return nil, err } values = []interface{}{single} } return actionArrayToMap(actionName, values), nil } func actionArrayToMap(actionName string, values []interface{}) map[string]interface{} { params := map[string]interface{}{} first := firstValue(values) switch actionName { case "assign_agent": params["assignee_id"] = first case "assign_team": params["team_id"] = first case "send_email_to_team": params["team_ids"] = values case "add_label", "remove_label": params["labels"] = valuesToStrings(values) case "change_status": params["status"] = first case "change_priority": params["priority"] = first case "send_message", "add_private_note": params["content"] = first case "send_email_to_contact", "send_email_transcript": params["email"] = first case "send_webhook_event": params["url"] = first case "send_attachment": params["blob_id"] = first case "add_sla": params["sla_policy_id"] = first default: params["values"] = values } return params } func chatwootActionParams(action automation.Action) interface{} { params := action.ActionParams if params == nil { return []interface{}{} } switch action.ActionName { case "assign_agent": return compactValues(params["assignee_id"], params["agent_id"]) case "assign_team": return compactValues(params["team_id"]) case "send_email_to_team": return gin.H{ "team_ids": valuesToInterfaces(firstNonNil(params["team_ids"], params["team_id"])), "message": firstNonNil(params["message"], params["content"]), } case "add_label", "remove_label": if labels, ok := params["labels"]; ok { return valuesToInterfaces(labels) } return compactValues(params["label"]) case "change_status": return compactValues(params["status"]) case "change_priority": return compactValues(params["priority"]) case "send_message", "add_private_note": return compactValues(params["content"], params["message"]) case "send_email_to_contact", "send_email_transcript": return compactValues(params["email"]) case "send_webhook_event": return compactValues(params["url"], params["webhook_url"]) case "send_attachment": return compactValues(params["blob_id"], params["attachment_url"]) case "add_sla": return compactValues(params["sla_policy_id"]) default: if values, ok := params["values"]; ok { return valuesToInterfaces(values) } return []interface{}{} } } func firstNonNil(values ...interface{}) interface{} { for _, value := range values { if value != nil { return value } } return nil } func firstValue(values []interface{}) interface{} { if len(values) == 0 { return nil } return values[0] } func compactValues(values ...interface{}) []interface{} { for _, value := range values { if value != nil { return []interface{}{value} } } return []interface{}{} } func valuesToInterfaces(value interface{}) []interface{} { switch typed := value.(type) { case []interface{}: return typed case []string: result := make([]interface{}, 0, len(typed)) for _, item := range typed { result = append(result, item) } return result case []uint: result := make([]interface{}, 0, len(typed)) for _, item := range typed { result = append(result, item) } return result case []int: result := make([]interface{}, 0, len(typed)) for _, item := range typed { result = append(result, item) } return result case nil: return []interface{}{} default: return []interface{}{typed} } } func valuesToStrings(values []interface{}) []string { result := make([]string, 0, len(values)) for _, value := range values { if value != nil { result = append(result, valueToString(value)) } } return result } func valueToString(value interface{}) string { switch typed := value.(type) { case string: return typed case json.Number: return typed.String() default: return jsonNumberSafeString(typed) } } func jsonNumberSafeString(value interface{}) string { bytes, err := json.Marshal(value) if err != nil { return "" } return string(bytes) }