362 lines
13 KiB
Go
362 lines
13 KiB
Go
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
|
|
}
|