package service import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" applogger "github.com/gochat/gochat/pkg/logger" pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // CsatTemplateService implements business logic for CSAT template management. // Reference: Chatwoot app/controllers/api/v1/accounts/inboxes/inbox_csat_templates_controller.rb // CSAT templates are singular resources per inbox — show (get template status), create (submit template), // and analyze (check template quality via Captain AI). type CsatTemplateService struct { repo *repository.CsatTemplateRepo provider CsatTemplateProvider worker *worker.WorkerPool } // NewCsatTemplateService creates a new CsatTemplate service. func NewCsatTemplateService(repo *repository.CsatTemplateRepo) *CsatTemplateService { return &CsatTemplateService{repo: repo, provider: defaultCsatTemplateProvider{}} } func (s *CsatTemplateService) SetWorkerPool(wp *worker.WorkerPool) { s.worker = wp RegisterCsatTemplateJobs(wp, s) } func (s *CsatTemplateService) SetProvider(provider CsatTemplateProvider) { if provider != nil { s.provider = provider } } // CreateCsatTemplateRequest is the DTO for creating a CSAT template. // Reference: Chatwoot InboxCsatTemplatesController#create — params: {message} type CreateCsatTemplateRequest struct { Message string `json:"message" validate:"required,min=1"` ButtonText string `json:"button_text,omitempty"` Language string `json:"language,omitempty"` } // AnalyzeCsatTemplateRequest is the DTO for analyzing a CSAT template. // Reference: Chatwoot InboxCsatTemplatesController#analyze — params: {message} type AnalyzeCsatTemplateRequest struct { Message string `json:"message" validate:"required,min=1"` ButtonText string `json:"button_text,omitempty"` Language string `json:"language,omitempty"` } type CsatTemplateProvider interface { CreateTemplate(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate, req CreateCsatTemplateRequest) (*CsatTemplateProviderResult, error) GetTemplateStatus(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate) (*CsatTemplateProviderResult, error) } type CsatTemplateProviderResult struct { TemplateExists bool Status string TemplateName string TemplateID string FriendlyName string ContentSID string ApprovalSID string Language string Error string ResponseBody string } type CsatTemplateStatusResult struct { TemplateExists bool `json:"template_exists"` Status string `json:"status,omitempty"` TemplateName string `json:"template_name,omitempty"` TemplateID string `json:"template_id,omitempty"` FriendlyName string `json:"friendly_name,omitempty"` ContentSID string `json:"content_sid,omitempty"` Language string `json:"language,omitempty"` Error string `json:"error,omitempty"` } // CsatTemplateAnalysisResult represents the result of a CSAT template quality analysis. // Placeholder implementation — real analysis requires Captain AI integration (WhatsApp template validation). type CsatTemplateAnalysisResult struct { Quality string `json:"quality"` // e.g. "good", "poor", "needs_improvement" Suggestions []string `json:"suggestions"` // improvement suggestions IsApproved bool `json:"is_approved"` // whether template would likely pass WhatsApp approval Message string `json:"message"` // original message analyzed } // ShowTemplateStatus retrieves the CSAT template status for an inbox. // If no template exists, returns nil to indicate "no template configured". // Reference: Chatwoot InboxCsatTemplatesController#show func (s *CsatTemplateService) ShowTemplateStatus(ctx context.Context, inboxID uint) (*model.CsatTemplate, error) { template, err := s.repo.FindByInbox(ctx, inboxID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { // No template configured — return nil to indicate "no template" (handler will return empty response) return nil, nil } return nil, err } return template, nil } func (s *CsatTemplateService) ShowTemplateStatusResult(ctx context.Context, inboxID uint) (*CsatTemplateStatusResult, error) { template, err := s.repo.FindByInbox(ctx, inboxID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return &CsatTemplateStatusResult{TemplateExists: false}, nil } return nil, err } var inbox model.Inbox if err := s.repo.DB().WithContext(ctx).First(&inbox, inboxID).Error; err != nil { return nil, err } result, err := s.provider.GetTemplateStatus(ctx, &inbox, template) if err != nil { return &CsatTemplateStatusResult{TemplateExists: false, Error: err.Error()}, nil } if result == nil || !result.TemplateExists { status := &CsatTemplateStatusResult{TemplateExists: false} if result != nil { status.Error = result.Error } return status, nil } return &CsatTemplateStatusResult{ TemplateExists: true, Status: providerStatusOrPending(result.Status), TemplateName: result.TemplateName, TemplateID: result.TemplateID, FriendlyName: result.FriendlyName, ContentSID: result.ContentSID, Language: result.Language, }, nil } // CreateTemplate creates a new CSAT template for an inbox. // If a template already exists for the inbox, updates it instead. // Reference: Chatwoot InboxCsatTemplatesController#create func (s *CsatTemplateService) CreateTemplate(ctx context.Context, inboxID uint, req CreateCsatTemplateRequest) (*model.CsatTemplate, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } // Check if template already exists for this inbox existing, err := s.repo.FindByInbox(ctx, inboxID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } if existing != nil { // Update existing template existing.Message = req.Message existing.Status = "pending" // Reset status to pending after message change if err := s.repo.Update(ctx, existing); err != nil { return nil, err } if err := s.enqueueOrCreateProviderTemplate(ctx, existing, req); err != nil { return existing, err } if s.worker == nil { existing, _ = s.repo.FindByID(ctx, existing.ID) } applogger.L().Infof("CSAT template updated for inbox %d", inboxID) return existing, nil } // Create new template template := &model.CsatTemplate{ InboxID: inboxID, Message: req.Message, Status: "pending", } if err := s.repo.Create(ctx, template); err != nil { return nil, err } if err := s.enqueueOrCreateProviderTemplate(ctx, template, req); err != nil { return template, err } if s.worker == nil { template, _ = s.repo.FindByID(ctx, template.ID) } applogger.L().Infof("CSAT template created for inbox %d", inboxID) return template, nil } func (s *CsatTemplateService) enqueueOrCreateProviderTemplate(ctx context.Context, template *model.CsatTemplate, req CreateCsatTemplateRequest) error { if s.worker != nil { _, err := s.worker.Enqueue(ctx, TaskTypeCsatTemplateCreate, csatTemplateCreateJob{TemplateID: template.ID, Request: req}, worker.WithQueue("automation"), worker.WithMaxAttempts(3), worker.WithIdempotencyKey(fmt.Sprintf("csat-template:%d:%d", template.ID, template.UpdatedAt.UnixNano()))) return err } return s.performTemplateCreate(ctx, template.ID, req) } func (s *CsatTemplateService) performTemplateCreate(ctx context.Context, templateID uint, req CreateCsatTemplateRequest) error { template, err := s.repo.FindByID(ctx, templateID) if err != nil { return err } var inbox model.Inbox if err := s.repo.DB().WithContext(ctx).First(&inbox, template.InboxID).Error; err != nil { return err } result, err := s.provider.CreateTemplate(ctx, &inbox, template, req) if err != nil { template.Status = "failed" _ = s.repo.Update(ctx, template) return err } template.Status = providerStatusOrPending(result.Status) if err := s.repo.Update(ctx, template); err != nil { return err } return s.updateInboxCsatTemplateConfig(ctx, &inbox, result) } func (s *CsatTemplateService) updateInboxCsatTemplateConfig(ctx context.Context, inbox *model.Inbox, result *CsatTemplateProviderResult) error { if result == nil || !result.TemplateExists { return nil } var config map[string]any if strings.TrimSpace(inbox.CsatConfig) != "" { _ = json.Unmarshal([]byte(inbox.CsatConfig), &config) } if config == nil { config = map[string]any{} } templateData := map[string]any{"status": providerStatusOrPending(result.Status), "created_at": time.Now().UTC().Format(time.RFC3339)} if result.Language != "" { templateData["language"] = result.Language } if result.TemplateName != "" { templateData["name"] = result.TemplateName } if result.TemplateID != "" { templateData["template_id"] = result.TemplateID } if result.FriendlyName != "" { templateData["friendly_name"] = result.FriendlyName } if result.ContentSID != "" { templateData["content_sid"] = result.ContentSID } if result.ApprovalSID != "" { templateData["approval_sid"] = result.ApprovalSID } config["template"] = templateData encoded, err := json.Marshal(config) if err != nil { return err } return s.repo.DB().WithContext(ctx).Model(&model.Inbox{}).Where("id = ?", inbox.ID).Update("csat_config", string(encoded)).Error } // AnalyzeTemplate analyzes a CSAT template message for quality. // Placeholder implementation — real WhatsApp template analysis requires Captain AI integration. // Reference: Chatwoot InboxCsatTemplatesController#analyze (captain_enabled check) func (s *CsatTemplateService) AnalyzeTemplate(ctx context.Context, inboxID uint, req AnalyzeCsatTemplateRequest) (*CsatTemplateAnalysisResult, error) { if err := pkgvalidator.ValidateStruct(req); err != nil { return nil, err } // Placeholder analysis logic — real implementation would use Captain AI // to validate template against WhatsApp Business API requirements. result := &CsatTemplateAnalysisResult{ Message: req.Message, Quality: "good", IsApproved: true, Suggestions: []string{}, } // Basic heuristic checks if len(req.Message) < 10 { result.Quality = "poor" result.IsApproved = false result.Suggestions = append(result.Suggestions, "Message is too short for a CSAT survey template") } if len(req.Message) > 500 { result.Quality = "needs_improvement" result.Suggestions = append(result.Suggestions, "Message may be too long for WhatsApp template approval") } applogger.L().Infof("CSAT template analyzed for inbox %d, quality=%s", inboxID, result.Quality) return result, nil } type defaultCsatTemplateProvider struct{} func (defaultCsatTemplateProvider) CreateTemplate(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate, req CreateCsatTemplateRequest) (*CsatTemplateProviderResult, error) { language := req.Language if language == "" { language = "en" } baseName := csatTemplateName(inbox.ID) if isTwilioWhatsAppInbox(inbox) { return &CsatTemplateProviderResult{ TemplateExists: true, Status: "PENDING", FriendlyName: baseName, ContentSID: fmt.Sprintf("HXCSAT%08d", template.ID), ApprovalSID: fmt.Sprintf("HACSAT%08d", template.ID), Language: language, }, nil } return &CsatTemplateProviderResult{ TemplateExists: true, Status: "PENDING", TemplateName: baseName, TemplateID: fmt.Sprintf("csat_template_%d", template.ID), Language: language, }, nil } func (defaultCsatTemplateProvider) GetTemplateStatus(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate) (*CsatTemplateProviderResult, error) { if template == nil { return &CsatTemplateProviderResult{TemplateExists: false}, nil } result := &CsatTemplateProviderResult{TemplateExists: true, Status: providerStatusOrPending(template.Status), Language: "en"} var config struct { Template map[string]any `json:"template"` } if inbox != nil && strings.TrimSpace(inbox.CsatConfig) != "" { _ = json.Unmarshal([]byte(inbox.CsatConfig), &config) } if len(config.Template) == 0 { return result, nil } result.Status = firstConfigString(config.Template, "status", result.Status) result.Language = firstConfigString(config.Template, "language", result.Language) result.TemplateName = firstConfigString(config.Template, "name", "") result.TemplateID = firstConfigString(config.Template, "template_id", "") result.FriendlyName = firstConfigString(config.Template, "friendly_name", "") result.ContentSID = firstConfigString(config.Template, "content_sid", "") result.ApprovalSID = firstConfigString(config.Template, "approval_sid", "") return result, nil } func providerStatusOrPending(status string) string { if strings.TrimSpace(status) == "" { return "PENDING" } return status } func csatTemplateName(inboxID uint) string { return fmt.Sprintf("csat_survey_%d", inboxID) } func isTwilioWhatsAppInbox(inbox *model.Inbox) bool { if inbox == nil { return false } channelType := strings.ToLower(inbox.ChannelType) return strings.Contains(channelType, "twilio") && (strings.Contains(channelType, "whatsapp") || strings.Contains(strings.ToLower(inbox.ChannelConfig), "whatsapp")) } func firstConfigString(config map[string]any, key, fallback string) string { if raw, ok := config[key]; ok { if value := strings.TrimSpace(fmt.Sprintf("%v", raw)); value != "" { return value } } return fallback }