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