package automation import ( "context" "encoding/json" "fmt" "strconv" "strings" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // MacroService provides CRUD + execution for macros. // Reference: Chatwoot Macros::ExecutionService — same action handler pattern as AutomationRule, // but user-originated (supports 'self' assign). stamps user info. type MacroService struct { db DBProvider worker *worker.WorkerPool } const TaskTypeMacroExecution = "automation:macro_execution" // NewMacroService creates a new MacroService. func NewMacroService(db DBProvider) *MacroService { return &MacroService{db: db} } func NewMacroServiceWithWorker(db DBProvider, wp *worker.WorkerPool) *MacroService { s := NewMacroService(db) s.SetWorkerPool(wp) return s } func (s *MacroService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp RegisterActionDeliveryJobs(wp, s.db) RegisterMacroExecutionJobs(wp, s.db) } // GetByID retrieves a macro by ID. func (s *MacroService) GetByID(ctx context.Context, id uint) (*Macro, error) { var macro Macro if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(¯o, id).Error; err != nil { return nil, err } _ = s.hydrateMacroFiles(ctx, ¯o) return ¯o, nil } // GetByIDForAccount retrieves a macro scoped to an account. func (s *MacroService) GetByIDForAccount(ctx context.Context, accountID, id uint) (*Macro, error) { var macro Macro if err := s.db.DB().WithContext(ctx). Preload("CreatedBy").Preload("UpdatedBy"). Where("account_id = ?", accountID). First(¯o, id).Error; err != nil { return nil, err } _ = s.hydrateMacroFiles(ctx, ¯o) return ¯o, nil } // ListByAccount retrieves all macros for an account, respecting visibility. // Personal macros are only visible to their creator; global macros are visible to all. func (s *MacroService) ListByAccount(ctx context.Context, accountID uint, userID uint) ([]Macro, error) { var macros []Macro query := s.db.DB().WithContext(ctx). Where("account_id = ?", accountID) // Filter by visibility: show global macros + personal macros owned by this user query = query.Where("visibility = ? OR (visibility = ? AND created_by_id = ?)", MacroVisibilityGlobal, MacroVisibilityPersonal, userID) if err := query.Preload("CreatedBy").Preload("UpdatedBy").Order("id ASC").Find(¯os).Error; err != nil { return nil, err } for i := range macros { _ = s.hydrateMacroFiles(ctx, ¯os[i]) } return macros, nil } // Create creates a new macro. func (s *MacroService) Create(ctx context.Context, macro *Macro) error { if strings.TrimSpace(macro.Name) == "" { return fmt.Errorf("name is required") } if macro.Actions == nil { macro.Actions = Actions{} } if err := s.normalizeMacroAttachmentActions(ctx, macro.AccountID, ¯o.Actions); err != nil { return err } macro.Active = true // Use Select to force all fields including zero-value bool Active=false. // Without Select, GORM skips zero-value fields and uses column defaults. // Callers should explicitly set Active=true when creating new macros. if err := s.db.DB().WithContext(ctx).Select( "AccountID", "Name", "Actions", "Visibility", "Active", "CreatedByID", "UpdatedByID", ).Create(macro).Error; err != nil { return err } if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error; err != nil { return err } return s.hydrateMacroFiles(ctx, macro) } // Update updates an existing macro. func (s *MacroService) Update(ctx context.Context, macro *Macro) error { return s.db.DB().WithContext(ctx).Save(macro).Error } // UpdateForAccount updates an existing macro within account scope while preserving ownership. func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, macro *Macro) (*Macro, error) { existing, err := s.GetByIDForAccount(ctx, accountID, macro.ID) if err != nil { return nil, err } existing.Name = macro.Name if macro.Actions != nil { if err := s.normalizeMacroAttachmentActions(ctx, accountID, ¯o.Actions); err != nil { return nil, err } existing.Actions = macro.Actions } existing.Visibility = macro.Visibility existing.UpdatedByID = macro.UpdatedByID if err := s.Update(ctx, existing); err != nil { return nil, err } return s.GetByIDForAccount(ctx, accountID, existing.ID) } func (s *MacroService) normalizeMacroAttachmentActions(ctx context.Context, accountID uint, actions *Actions) error { if actions == nil { return nil } for i := range *actions { action := &(*actions)[i] if action.ActionName != "send_attachment" { continue } if action.ActionParams == nil { return fmt.Errorf("invalid attachment") } blobID := firstMacroBlobID(action.ActionParams) upload, err := s.findMacroUpload(ctx, accountID, blobID) if err != nil { return fmt.Errorf("invalid attachment") } action.ActionParams["blob_id"] = upload.ID delete(action.ActionParams, "attachment_url") } return nil } func firstMacroBlobID(params map[string]interface{}) interface{} { if value := params["blob_id"]; value != nil { return value } return params["attachment_url"] } func (s *MacroService) findMacroUpload(ctx context.Context, accountID uint, value interface{}) (*model.DirectUpload, error) { switch v := value.(type) { case string: if parsed, err := strconv.ParseUint(v, 10, 64); err == nil { return s.findMacroUploadByID(ctx, accountID, uint(parsed)) } var upload model.DirectUpload if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND upload_uuid = ?", accountID, v).First(&upload).Error; err != nil { return nil, err } return &upload, nil case float64: return s.findMacroUploadByID(ctx, accountID, uint(v)) case int: return s.findMacroUploadByID(ctx, accountID, uint(v)) case uint: return s.findMacroUploadByID(ctx, accountID, v) case json.Number: parsed, err := strconv.ParseUint(string(v), 10, 64) if err != nil { return nil, err } return s.findMacroUploadByID(ctx, accountID, uint(parsed)) default: return nil, gorm.ErrRecordNotFound } } func (s *MacroService) findMacroUploadByID(ctx context.Context, accountID, id uint) (*model.DirectUpload, error) { var upload model.DirectUpload if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&upload).Error; err != nil { return nil, err } return &upload, nil } func (s *MacroService) hydrateMacroFiles(ctx context.Context, macro *Macro) error { ids := macroAttachmentBlobIDs(macro.Actions) if len(ids) == 0 { macro.Files = nil return nil } var uploads []model.DirectUpload if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id IN ?", macro.AccountID, ids).Order("id ASC").Find(&uploads).Error; err != nil { return err } files := make([]MacroFile, 0, len(uploads)) for _, upload := range uploads { files = append(files, MacroFile{ID: upload.ID, MacroID: macro.ID, FileType: upload.MimeType, AccountID: upload.AccountID, FileURL: upload.FileURL, BlobID: upload.ID, Filename: upload.OriginalName}) } macro.Files = files return nil } func macroAttachmentBlobIDs(actions Actions) []uint { seen := map[uint]bool{} ids := []uint{} for _, action := range actions { if action.ActionName != "send_attachment" { continue } id := macroBlobIDAsUint(action.ActionParams["blob_id"]) if id == 0 || seen[id] { continue } seen[id] = true ids = append(ids, id) } return ids } func macroBlobIDAsUint(value interface{}) uint { switch v := value.(type) { case uint: return v case int: return uint(v) case float64: return uint(v) case string: parsed, _ := strconv.ParseUint(v, 10, 64) return uint(parsed) case json.Number: parsed, _ := strconv.ParseUint(string(v), 10, 64) return uint(parsed) default: return 0 } } // Delete deletes a macro by ID. func (s *MacroService) Delete(ctx context.Context, id uint) error { return s.db.DB().WithContext(ctx).Delete(&Macro{}, id).Error } // DeleteForAccount deletes a macro within account scope. func (s *MacroService) DeleteForAccount(ctx context.Context, accountID, id uint) error { macro, err := s.GetByIDForAccount(ctx, accountID, id) if err != nil { return err } return s.db.DB().WithContext(ctx).Delete(macro).Error } // Clone creates a copy of a macro with "(copy)" appended to the name. // Reference: Chatwoot Macro clone — duplicates actions, resets ID, stamps creator. func (s *MacroService) Clone(ctx context.Context, id uint) (*Macro, error) { original, err := s.GetByID(ctx, id) if err != nil { return nil, fmt.Errorf("failed to load macro %d for clone: %w", id, err) } cloned := &Macro{ AccountID: original.AccountID, Name: original.Name + " (copy)", Actions: original.Actions, Visibility: original.Visibility, Active: original.Active, CreatedByID: original.CreatedByID, UpdatedByID: original.UpdatedByID, } if err := s.Create(ctx, cloned); err != nil { return nil, fmt.Errorf("failed to create cloned macro: %w", err) } return cloned, nil } // ToggleActive flips the active state of a macro. func (s *MacroService) ToggleActive(ctx context.Context, id uint, active bool) error { return s.db.DB().WithContext(ctx). Model(&Macro{}). Where("id = ?", id). Update("active", active).Error } // Execute runs a macro's actions on a conversation. // Reference: Chatwoot Macros::ExecutionService stamps user info (executed_by) // and supports 'self' assignment (assign to the user running the macro). // Template variables in action params (e.g. {{contact.name}}) are resolved // by ActionService before execution — the macro layer injects _source_user_id // for 'self' assignment support and delegates execution to ActionService. func (s *MacroService) Execute(ctx context.Context, accountID uint, conversationID uint, macroID uint, userID uint) error { macro, err := s.GetByID(ctx, macroID) if err != nil { return fmt.Errorf("failed to load macro %d: %w", macroID, err) } applogger.L().Infof("executing macro %d (%s) on conversation %d by user %d", macro.ID, macro.Name, conversationID, userID) actionSvc := NewActionServiceWithWorker(s.db, s.worker) for _, action := range macro.Actions { // Inject _source_user_id for "self" assignment support params := action.ActionParams if params == nil { params = map[string]interface{}{} } params["_source_user_id"] = userID resolvedAction := Action{ ActionName: action.ActionName, ActionParams: params, } // ActionService.Execute resolves template variables internally // ({{contact.name}}, {{conversation.status}}, etc.) before running the action. if err := actionSvc.Execute(ctx, accountID, conversationID, resolvedAction, ActionSourceMacro, userID); err != nil { applogger.L().Errorf("macro action %s failed for macro %d on conversation %d: %v", action.ActionName, macroID, conversationID, err) // Continue executing remaining actions (Chatwoot pattern) } } // Stamp execution record for audit trail // Reference: Chatwoot stamps user info (executed_by_id) return s.recordExecution(ctx, macroID, conversationID, userID) } // ExecuteForDisplayIDs runs a macro against account-scoped conversation display IDs. // Reference: Chatwoot MacrosExecutionJob resolves account.conversations by display_id. func (s *MacroService) ExecuteForDisplayIDs(ctx context.Context, accountID uint, macroID uint, displayIDs []uint, userID uint) error { if s.worker != nil { return s.enqueueExecuteForDisplayIDs(ctx, accountID, macroID, displayIDs, userID) } return s.executeForDisplayIDsNow(ctx, accountID, macroID, displayIDs, userID) } func (s *MacroService) enqueueExecuteForDisplayIDs(ctx context.Context, accountID uint, macroID uint, displayIDs []uint, userID uint) error { if len(displayIDs) == 0 { return nil } _, err := s.GetByIDForAccount(ctx, accountID, macroID) if err != nil { return err } _, err = s.worker.Enqueue(ctx, TaskTypeMacroExecution, macroExecutionJob{ AccountID: accountID, MacroID: macroID, ConversationIDs: displayIDs, UserID: userID, }, worker.WithQueue("medium"), worker.WithMaxAttempts(3)) return err } func (s *MacroService) executeForDisplayIDsNow(ctx context.Context, accountID uint, macroID uint, displayIDs []uint, userID uint) error { macro, err := s.GetByIDForAccount(ctx, accountID, macroID) if err != nil { return err } if len(displayIDs) == 0 { return nil } var conversations []model.Conversation if err := s.db.DB().WithContext(ctx). Where("account_id = ? AND display_id IN ?", accountID, displayIDs). Find(&conversations).Error; err != nil { return err } if len(conversations) == 0 { return nil } actionSvc := NewActionServiceWithWorker(s.db, s.worker) for _, conversation := range conversations { applogger.L().Infof("executing macro %d (%s) on conversation %d by user %d", macro.ID, macro.Name, conversation.ID, userID) for _, action := range macro.Actions { params := map[string]interface{}{} for key, value := range action.ActionParams { params[key] = value } params["_source_user_id"] = userID resolvedAction := Action{ActionName: action.ActionName, ActionParams: params} if err := actionSvc.Execute(ctx, accountID, conversation.ID, resolvedAction, ActionSourceMacro, userID); err != nil { applogger.L().Errorf("macro action %s failed for macro %d on conversation %d: %v", action.ActionName, macroID, conversation.ID, err) } } if err := s.recordExecution(ctx, macroID, conversation.ID, userID); err != nil && err != gorm.ErrRecordNotFound { applogger.L().Warnf("failed to record macro %d execution for conversation %d: %v", macroID, conversation.ID, err) } } return nil } // CanAccess matches Chatwoot MacroPolicy for show/update/destroy/execute. func (s *MacroService) CanAccess(macro *Macro, userID uint, role string, action string) bool { if macro == nil { return false } if action == "show" || action == "execute" { return macro.Visibility == MacroVisibilityGlobal || macro.CreatedByID == userID } if action == "update" || action == "destroy" { if macro.Visibility == MacroVisibilityGlobal { return role == "administrator" || role == "super_admin" } return macro.CreatedByID == userID } return false } // recordExecution logs that a macro was executed on a conversation by a user. func (s *MacroService) recordExecution(ctx context.Context, macroID, conversationID, userID uint) error { record := &MacroExecution{ MacroID: macroID, ConversationID: conversationID, ExecutedByID: userID, } return s.db.DB().WithContext(ctx).Create(record).Error } // MacroExecution records a macro execution event for audit trail. type MacroExecution struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` MacroID uint `gorm:"index;not null" json:"macro_id"` ConversationID uint `gorm:"index;not null" json:"conversation_id"` ExecutedByID uint `gorm:"index;not null" json:"executed_by_id"` } func (MacroExecution) TableName() string { return "macro_executions" }