package service import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "github.com/gochat/gochat/internal/campaign" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // CampaignService implements business logic for Campaign operations. // Reference: Chatwoot app/controllers/api/v1/campaigns_controller.rb type CampaignService struct { campaignSvc *campaign.CampaignService campaignRepo *repository.CampaignRepo } // NewCampaignService creates a new Campaign service. func NewCampaignService(campaignSvc *campaign.CampaignService, campaignRepo *repository.CampaignRepo) *CampaignService { return &CampaignService{campaignSvc: campaignSvc, campaignRepo: campaignRepo} } // CreateCampaignRequest is the DTO for creating a campaign. type CreateCampaignRequest struct { InboxID uint `json:"inbox_id" validate:"required"` SenderID *uint `json:"sender_id,omitempty"` Title string `json:"title" validate:"required,min=2"` Message string `json:"message" validate:"required"` Description string `json:"description,omitempty"` CampaignType string `json:"campaign_type" validate:"omitempty,oneof=ongoing one_off"` Audience json.RawMessage `json:"audience,omitempty"` TriggerRules json.RawMessage `json:"trigger_rules,omitempty"` TemplateParams json.RawMessage `json:"template_params,omitempty"` ScheduledAt *string `json:"scheduled_at,omitempty"` // ISO8601 timestamp string Enabled *bool `json:"enabled,omitempty"` TriggerOnlyDuringBusinessHours *bool `json:"trigger_only_during_business_hours,omitempty"` } // UpdateCampaignRequest is the DTO for updating a campaign. type UpdateCampaignRequest struct { InboxID uint `json:"inbox_id,omitempty"` SenderID *uint `json:"sender_id,omitempty"` Title string `json:"title,omitempty" validate:"omitempty,min=2"` Message string `json:"message,omitempty"` Description string `json:"description,omitempty"` CampaignType string `json:"campaign_type,omitempty" validate:"omitempty,oneof=ongoing one_off"` Audience json.RawMessage `json:"audience,omitempty"` TriggerRules json.RawMessage `json:"trigger_rules,omitempty"` TemplateParams json.RawMessage `json:"template_params,omitempty"` ScheduledAt *string `json:"scheduled_at,omitempty"` Enabled *bool `json:"enabled,omitempty"` TriggerOnlyDuringBusinessHours *bool `json:"trigger_only_during_business_hours,omitempty"` inboxIDSet bool senderIDSet bool descriptionSet bool scheduledAtSet bool } func (r *UpdateCampaignRequest) UnmarshalJSON(data []byte) error { type alias UpdateCampaignRequest var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { return err } var decoded alias if err := json.Unmarshal(data, &decoded); err != nil { return err } *r = UpdateCampaignRequest(decoded) _, r.inboxIDSet = raw["inbox_id"] _, r.senderIDSet = raw["sender_id"] _, r.descriptionSet = raw["description"] _, r.scheduledAtSet = raw["scheduled_at"] return nil } func (r UpdateCampaignRequest) InboxIDSet() bool { return r.inboxIDSet } func (r UpdateCampaignRequest) SenderIDSet() bool { return r.senderIDSet } func (r UpdateCampaignRequest) DescriptionSet() bool { return r.descriptionSet } func (r UpdateCampaignRequest) ScheduledAtSet() bool { return r.scheduledAtSet } // List retrieves all campaigns for an account with pagination. func (s *CampaignService) List(ctx context.Context, accountID uint, offset, limit int) ([]campaign.Campaign, int64, error) { return s.campaignRepo.ListByAccount(ctx, accountID, offset, limit) } // Get retrieves a single campaign by ID scoped to an account. func (s *CampaignService) Get(ctx context.Context, id, accountID uint) (*campaign.Campaign, error) { c, err := s.campaignRepo.FindByIDAndAccount(ctx, id, accountID) if err != nil { return nil, fmt.Errorf("campaign not found: %w", err) } return c, nil } // Create creates a new campaign within an account. func (s *CampaignService) Create(ctx context.Context, accountID uint, req CreateCampaignRequest) (*campaign.Campaign, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } enabled := true if req.Enabled != nil { enabled = *req.Enabled } triggerDuringBH := false if req.TriggerOnlyDuringBusinessHours != nil { triggerDuringBH = *req.TriggerOnlyDuringBusinessHours } inbox, err := s.campaignRepo.FindInboxByIDAndAccount(ctx, req.InboxID, accountID) if err != nil { return nil, fmt.Errorf("inbox not found: %w", err) } if err := s.validateCampaignSender(ctx, accountID, req.SenderID); err != nil { return nil, err } campaignType, scheduledAt, err := deriveCampaignAttributes(*inbox, req.ScheduledAt, nil) if err != nil { return nil, err } c := &campaign.Campaign{ AccountID: accountID, InboxID: req.InboxID, SenderID: req.SenderID, Title: req.Title, Message: req.Message, Description: req.Description, CampaignStatus: campaign.CampaignStatusActive, CampaignType: campaignType, Audience: rawJSONParamString(req.Audience), TriggerRules: rawJSONParamString(req.TriggerRules), TemplateParams: rawJSONParamString(req.TemplateParams), ScheduledAt: scheduledAt, Enabled: enabled, TriggerOnlyDuringBusinessHours: triggerDuringBH, Inbox: *inbox, } if err := s.campaignSvc.Create(ctx, c); err != nil { applogger.L().Errorf("failed to create campaign: %v", err) return nil, fmt.Errorf("failed to create campaign: %w", err) } return c, nil } // Update updates an existing campaign scoped to an account. func (s *CampaignService) Update(ctx context.Context, id, accountID uint, req UpdateCampaignRequest) (*campaign.Campaign, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } c, err := s.campaignRepo.FindByIDAndAccount(ctx, id, accountID) if err != nil { return nil, fmt.Errorf("campaign not found: %w", err) } inbox := &c.Inbox if req.InboxIDSet() { if req.InboxID == 0 { return nil, fmt.Errorf("invalid inbox_id") } resolvedInbox, err := s.campaignRepo.FindInboxByIDAndAccount(ctx, req.InboxID, accountID) if err != nil { return nil, fmt.Errorf("inbox not found: %w", err) } inbox = resolvedInbox c.InboxID = req.InboxID } if req.SenderIDSet() { if err := s.validateCampaignSender(ctx, accountID, req.SenderID); err != nil { return nil, err } c.SenderID = req.SenderID } campaignType, scheduledAt, err := deriveCampaignAttributes(*inbox, req.ScheduledAt, c.ScheduledAt) if err != nil { return nil, err } c.CampaignType = campaignType c.ScheduledAt = scheduledAt if req.Title != "" { c.Title = req.Title } if req.Message != "" { c.Message = req.Message } if req.DescriptionSet() { c.Description = req.Description } if len(req.Audience) > 0 { c.Audience = rawJSONParamString(req.Audience) } if len(req.TriggerRules) > 0 { c.TriggerRules = rawJSONParamString(req.TriggerRules) } if len(req.TemplateParams) > 0 { c.TemplateParams = rawJSONParamString(req.TemplateParams) } if req.Enabled != nil { c.Enabled = *req.Enabled } if req.TriggerOnlyDuringBusinessHours != nil { c.TriggerOnlyDuringBusinessHours = *req.TriggerOnlyDuringBusinessHours } if err := s.campaignSvc.Update(ctx, c.ID, map[string]interface{}{ "inbox_id": c.InboxID, "sender_id": c.SenderID, "title": c.Title, "message": c.Message, "description": c.Description, "campaign_type": c.CampaignType, "audience": c.Audience, "trigger_rules": c.TriggerRules, "template_params": c.TemplateParams, "scheduled_at": c.ScheduledAt, "enabled": c.Enabled, "trigger_only_during_business_hours": c.TriggerOnlyDuringBusinessHours, }); err != nil { applogger.L().Errorf("failed to update campaign: %v", err) return nil, fmt.Errorf("failed to update campaign: %w", err) } reloadedID := c.DisplayID if reloadedID == 0 { reloadedID = c.ID } return s.campaignRepo.FindByDisplayIDAndAccountOrID(ctx, reloadedID, accountID) } // Delete soft-deletes a campaign scoped to an account. func (s *CampaignService) Delete(ctx context.Context, id, accountID uint) error { c, err := s.campaignRepo.FindByDisplayIDAndAccountOrID(ctx, id, accountID) if err != nil { return fmt.Errorf("campaign not found: %w", err) } if err := s.campaignSvc.Delete(ctx, c.ID); err != nil { return fmt.Errorf("failed to delete campaign: %w", err) } return nil } // Start triggers a campaign execution. func (s *CampaignService) Start(ctx context.Context, id, accountID uint) error { c, err := s.campaignRepo.FindByIDAndAccount(ctx, id, accountID) if err != nil { return fmt.Errorf("campaign not found: %w", err) } if err := s.campaignSvc.TriggerCampaign(ctx, c.ID); err != nil { return fmt.Errorf("failed to trigger campaign: %w", err) } return nil } func rawJSONParamString(raw json.RawMessage) string { trimmed := strings.TrimSpace(string(raw)) if trimmed == "" || trimmed == "null" { return "" } var value string if err := json.Unmarshal(raw, &value); err == nil { return value } return trimmed } func (s *CampaignService) validateCampaignSender(ctx context.Context, accountID uint, senderID *uint) error { if senderID == nil || *senderID == 0 { return nil } ok, err := s.campaignRepo.AccountHasUser(ctx, accountID, *senderID) if err != nil { return fmt.Errorf("validate sender: %w", err) } if !ok { return fmt.Errorf("invalid sender_id: must belong to the same account as the campaign") } return nil } func deriveCampaignAttributes(inbox model.Inbox, scheduledAt *string, current *time.Time) (campaign.CampaignType, *time.Time, error) { if campaignInboxIsOneOff(inbox.ChannelType) { parsed, err := parseCampaignScheduledAt(scheduledAt) if err != nil { return "", nil, err } if parsed == nil { if current != nil { copy := current.UTC() parsed = © } else { now := time.Now().UTC() parsed = &now } } return campaign.CampaignTypeOneOff, parsed, nil } if campaignInboxIsOngoing(inbox.ChannelType) { return campaign.CampaignTypeOngoing, nil, nil } return "", nil, fmt.Errorf("invalid inbox: Unsupported Inbox type") } func campaignInboxIsOneOff(channelType string) bool { normalized := normalizeCampaignInboxType(channelType) return normalized == "sms" || normalized == "twiliosms" || normalized == "whatsapp" } func campaignInboxIsOngoing(channelType string) bool { normalized := normalizeCampaignInboxType(channelType) return normalized == "webwidget" || normalized == "website" } func normalizeCampaignInboxType(channelType string) string { normalized := strings.ToLower(strings.TrimSpace(channelType)) normalized = strings.TrimPrefix(normalized, "channel::") normalized = strings.ReplaceAll(normalized, "_", "") normalized = strings.ReplaceAll(normalized, " ", "") normalized = strings.ReplaceAll(normalized, "-", "") return normalized } func parseCampaignScheduledAt(raw *string) (*time.Time, error) { if raw == nil || strings.TrimSpace(*raw) == "" { return nil, nil } trimmed := strings.TrimSpace(*raw) if unix, err := strconv.ParseInt(trimmed, 10, 64); err == nil { parsed := time.Unix(unix, 0).UTC() return &parsed, nil } for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05 -0700", "2006-01-02 15:04:05"} { if parsed, err := time.Parse(layout, trimmed); err == nil { parsed = parsed.UTC() return &parsed, nil } } return nil, fmt.Errorf("invalid scheduled_at") } // Stop marks a campaign as completed. func (s *CampaignService) Stop(ctx context.Context, id, accountID uint) error { c, err := s.campaignRepo.FindByIDAndAccount(ctx, id, accountID) if err != nil { return fmt.Errorf("campaign not found: %w", err) } if err := s.campaignSvc.MarkCompleted(ctx, c.ID); err != nil { return fmt.Errorf("failed to stop campaign: %w", err) } return nil }