Files
gochat/internal/campaign/service.go
T

167 lines
5.3 KiB
Go

package campaign
import (
"context"
"encoding/json"
"fmt"
"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
}
func NewCampaignService(db *gorm.DB) *CampaignService {
return &CampaignService{db: db}
}
// 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)
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
}
func NewCampaignConversationBuilder(db *gorm.DB) *CampaignConversationBuilder {
return &CampaignConversationBuilder{db: db}
}
// 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 {
// Parse audience to extract contact IDs
var audienceData struct {
ContactIDs []uint `json:"contact_ids"`
}
if err := json.Unmarshal([]byte(campaign.Audience), &audienceData); err != nil {
return fmt.Errorf("campaign: parse audience: %w", err)
}
for _, contactID := range audienceData.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
}
// 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
}
applogger.L().Info("campaign: created conversation from campaign",
"campaign_id", campaign.ID, "conversation_id", conv.ID, "contact_id", contactID)
}
return nil
}