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

551 lines
16 KiB
Go

package v1
import (
"encoding/json"
"net/http"
"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 {
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 {
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,
})
}
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 {
return 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,
}
}
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", "send_email_to_team":
params["team_id"] = first
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", "send_email_to_team":
return compactValues(params["team_id"])
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 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 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)
}