package campaign import ( "context" "encoding/json" "fmt" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // CampaignService provides business logic for campaign operations. // Reference: Chatwoot CampaignService — CRUD + campaign triggering. type CampaignService struct { db *gorm.DB dispatcher *channel.Dispatcher } func NewCampaignService(db *gorm.DB, dispatchers ...*channel.Dispatcher) *CampaignService { var dispatcher *channel.Dispatcher if len(dispatchers) > 0 { dispatcher = dispatchers[0] } return &CampaignService{db: db, dispatcher: dispatcher} } // Create creates a new campaign. func (s *CampaignService) Create(ctx context.Context, campaign *Campaign) error { if campaign.DisplayID == 0 { var next uint if err := s.db.WithContext(ctx).Model(&Campaign{}). Select("COALESCE(MAX(display_id), 0) + 1").Scan(&next).Error; err != nil { return err } campaign.DisplayID = next } return s.db.WithContext(ctx).Create(campaign).Error } // GetByID retrieves a campaign by ID. func (s *CampaignService) GetByID(ctx context.Context, id uint) (*Campaign, error) { var c Campaign if err := s.db.WithContext(ctx).First(&c, id).Error; err != nil { return nil, err } return &c, nil } // ListByAccount returns campaigns for an account with pagination. func (s *CampaignService) ListByAccount(ctx context.Context, accountID uint, offset, limit int) ([]Campaign, int64, error) { var campaigns []Campaign var count int64 db := s.db.WithContext(ctx).Model(&Campaign{}).Where("account_id = ?", accountID) db.Count(&count) if err := db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&campaigns).Error; err != nil { return nil, 0, err } return campaigns, count, nil } // ListByInbox returns campaigns for a specific inbox. func (s *CampaignService) ListByInbox(ctx context.Context, accountID, inboxID uint) ([]Campaign, error) { var campaigns []Campaign err := s.db.WithContext(ctx). Where("account_id = ? AND inbox_id = ?", accountID, inboxID). Order("created_at DESC").Find(&campaigns).Error return campaigns, err } // Update updates campaign fields. func (s *CampaignService) Update(ctx context.Context, id uint, updates map[string]interface{}) error { return s.db.WithContext(ctx).Model(&Campaign{}).Where("id = ?", id).Updates(updates).Error } // Delete soft-deletes a campaign. func (s *CampaignService) Delete(ctx context.Context, id uint) error { return s.db.WithContext(ctx).Delete(&Campaign{}, id).Error } // MarkCompleted transitions a campaign to completed status. func (s *CampaignService) MarkCompleted(ctx context.Context, id uint) error { return s.Update(ctx, id, map[string]interface{}{ "campaign_status": CampaignStatusCompleted, }) } // TriggerCampaign executes a campaign by creating conversations for the target audience. // Reference: Chatwoot CampaignService.trigger — builds conversation from campaign. func (s *CampaignService) TriggerCampaign(ctx context.Context, campaignID uint) error { campaign, err := s.GetByID(ctx, campaignID) if err != nil { return fmt.Errorf("campaign: get campaign: %w", err) } if !campaign.Enabled { applogger.L().Info("campaign: campaign is disabled, skipping trigger", "campaign_id", campaignID) return nil } builder := NewCampaignConversationBuilder(s.db, s.dispatcher) return builder.Build(ctx, campaign) } // CampaignConversationBuilder builds a conversation from a campaign trigger. // Reference: Chatwoot CampaignListener — on campaign_triggered event -> builds conversation. type CampaignConversationBuilder struct { db *gorm.DB dispatcher *channel.Dispatcher } func NewCampaignConversationBuilder(db *gorm.DB, dispatchers ...*channel.Dispatcher) *CampaignConversationBuilder { var dispatcher *channel.Dispatcher if len(dispatchers) > 0 { dispatcher = dispatchers[0] } return &CampaignConversationBuilder{db: db, dispatcher: dispatcher} } // Build creates a new conversation from the campaign for each contact in the audience. // The audience field (JSONB) contains contact IDs or filter criteria. func (b *CampaignConversationBuilder) Build(ctx context.Context, campaign *Campaign) error { contactIDs, err := campaignAudienceContactIDs(campaign.Audience) if err != nil { return fmt.Errorf("campaign: parse audience: %w", err) } inbox := b.loadInbox(ctx, campaign) for _, contactID := range contactIDs { conv := &model.Conversation{ AccountID: campaign.AccountID, InboxID: campaign.InboxID, ContactID: contactID, CampaignID: &campaign.ID, Status: "open", ChannelType: "campaign", } if campaign.SenderID != nil { conv.AssigneeID = campaign.SenderID } if err := b.db.WithContext(ctx).Create(conv).Error; err != nil { applogger.L().Error("campaign: failed to create conversation for contact", "campaign_id", campaign.ID, "contact_id", contactID, "error", err) continue } b.dispatch(ctx, channel.EventConversationCreated, campaign, inbox, conv, nil) b.dispatch(ctx, channel.EventConversationOpened, campaign, inbox, conv, nil) // Create the initial campaign message in the conversation msg := &model.Message{ ConversationID: conv.ID, AccountID: campaign.AccountID, InboxID: campaign.InboxID, Content: campaign.Message, ContentType: "template", MessageType: "outgoing", SenderType: "agent", } if campaign.SenderID != nil { msg.SenderID = campaign.SenderID } if err := b.db.WithContext(ctx).Create(msg).Error; err != nil { applogger.L().Error("campaign: failed to create campaign message", "campaign_id", campaign.ID, "conversation_id", conv.ID, "error", err) continue } b.dispatch(ctx, channel.EventMessageCreated, campaign, inbox, conv, msg) b.dispatch(ctx, channel.EventMessageOutgoing, campaign, inbox, conv, msg) applogger.L().Info("campaign: created conversation from campaign", "campaign_id", campaign.ID, "conversation_id", conv.ID, "contact_id", contactID) } return nil } func (b *CampaignConversationBuilder) loadInbox(ctx context.Context, c *Campaign) *model.Inbox { if c.Inbox.ID != 0 { return &c.Inbox } var inbox model.Inbox if err := b.db.WithContext(ctx).Where("account_id = ? AND id = ?", c.AccountID, c.InboxID).First(&inbox).Error; err != nil { return nil } return &inbox } func (b *CampaignConversationBuilder) dispatch(ctx context.Context, eventType channel.EventType, campaign *Campaign, inbox *model.Inbox, conversation *model.Conversation, message *model.Message) { if b.dispatcher == nil || campaign == nil || conversation == nil { return } channelType := channel.ChannelAPI inboxID := campaign.InboxID if inbox != nil { channelType = channel.ChannelType(inbox.ChannelType) inboxID = inbox.ID } event := channel.NewChannelEvent(eventType, channelType, campaign.AccountID, inboxID) event.ConversationID = conversation.ID event.ContactID = conversation.ContactID event.Data["campaign_id"] = campaign.ID event.Data["conversation"] = conversation if inbox != nil { event.Data["inbox"] = inbox } if message != nil { event.Data["message"] = message } if err := b.dispatcher.DispatchAsync(ctx, event); err != nil { applogger.L().Warn("campaign: failed to dispatch campaign event", "campaign_id", campaign.ID, "event", eventType, "error", err) } } func campaignAudienceContactIDs(raw string) ([]uint, error) { var audienceData struct { ContactIDs []uint `json:"contact_ids"` } if err := json.Unmarshal([]byte(raw), &audienceData); err == nil { return audienceData.ContactIDs, nil } var frontendRules []map[string]any if err := json.Unmarshal([]byte(raw), &frontendRules); err != nil { return nil, err } return []uint{}, nil }