feat(csat): queue channel templates
This commit is contained in:
@@ -666,6 +666,7 @@ func Bootstrap(env string) (*App, error) {
|
||||
|
||||
// Lane C: CSAT template + Inbox limit services
|
||||
csatTemplateService := service.NewCsatTemplateService(csatTemplateRepo)
|
||||
csatTemplateService.SetWorkerPool(workerPool)
|
||||
inboxLimitService := service.NewInboxLimitService(inboxLimitRepo)
|
||||
|
||||
// S6: WorkingHour service (out-of-office / business hours per inbox)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -31,18 +32,12 @@ func (h *InboxCsatTemplateHandler) Show(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
template, svcErr := h.svc.ShowTemplateStatus(c.Request.Context(), inboxID)
|
||||
status, svcErr := h.svc.ShowTemplateStatusResult(c.Request.Context(), inboxID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// If no template exists for this inbox, return empty object (Chatwoot pattern)
|
||||
if template == nil {
|
||||
response.OK(c, gin.H{})
|
||||
return
|
||||
}
|
||||
response.OK(c, template)
|
||||
response.OK(c, status)
|
||||
}
|
||||
|
||||
// Create creates or updates a CSAT template for an inbox.
|
||||
@@ -55,8 +50,8 @@ func (h *InboxCsatTemplateHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.CreateCsatTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req, err := bindCreateCsatTemplateRequest(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
@@ -67,7 +62,7 @@ func (h *InboxCsatTemplateHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, template)
|
||||
c.JSON(http.StatusCreated, template)
|
||||
}
|
||||
|
||||
// Analyze analyzes a CSAT template message for quality.
|
||||
@@ -80,8 +75,8 @@ func (h *InboxCsatTemplateHandler) Analyze(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req service.AnalyzeCsatTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
req, err := bindAnalyzeCsatTemplateRequest(c)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
@@ -93,4 +88,56 @@ func (h *InboxCsatTemplateHandler) Analyze(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.OK(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func bindCreateCsatTemplateRequest(c *gin.Context) (service.CreateCsatTemplateRequest, error) {
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
ButtonText string `json:"button_text"`
|
||||
Language string `json:"language"`
|
||||
Template service.CreateCsatTemplateRequest `json:"template"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
return service.CreateCsatTemplateRequest{}, err
|
||||
}
|
||||
req := body.Template
|
||||
if req.Message == "" {
|
||||
req.Message = body.Message
|
||||
}
|
||||
if req.ButtonText == "" {
|
||||
req.ButtonText = body.ButtonText
|
||||
}
|
||||
if req.Language == "" {
|
||||
req.Language = body.Language
|
||||
}
|
||||
if req.Message == "" {
|
||||
return req, errors.New("message is required")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func bindAnalyzeCsatTemplateRequest(c *gin.Context) (service.AnalyzeCsatTemplateRequest, error) {
|
||||
var body struct {
|
||||
Message string `json:"message"`
|
||||
ButtonText string `json:"button_text"`
|
||||
Language string `json:"language"`
|
||||
Template service.AnalyzeCsatTemplateRequest `json:"template"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
return service.AnalyzeCsatTemplateRequest{}, err
|
||||
}
|
||||
req := body.Template
|
||||
if req.Message == "" {
|
||||
req.Message = body.Message
|
||||
}
|
||||
if req.ButtonText == "" {
|
||||
req.ButtonText = body.ButtonText
|
||||
}
|
||||
if req.Language == "" {
|
||||
req.Language = body.Language
|
||||
}
|
||||
if req.Message == "" {
|
||||
return req, errors.New("message is required")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -91,6 +91,21 @@ func (s *InboxCsatTemplateHandlerTestSuite) TestCreate_BadRequest_InvalidInboxID
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func (s *InboxCsatTemplateHandlerTestSuite) TestCreate_ChatwootNestedTemplatePayload() {
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/csat_template", s.handler.Create)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := bytes.NewBufferString(`{"template":{"message":"Please rate our support","button_text":"Rate us","language":"en"}}`)
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/inboxes/%d/csat_template", s.account.ID, s.inbox.ID), body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusCreated, w.Code)
|
||||
assert.Contains(s.T(), w.Body.String(), "Please rate our support")
|
||||
assert.Contains(s.T(), w.Body.String(), "PENDING")
|
||||
}
|
||||
|
||||
func (s *InboxCsatTemplateHandlerTestSuite) TestAnalyze_BadRequest_InvalidInboxID() {
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/inboxes/:inbox_id/csat_template/analyze", s.handler.Analyze)
|
||||
@@ -101,4 +116,4 @@ func (s *InboxCsatTemplateHandlerTestSuite) TestAnalyze_BadRequest_InvalidInboxI
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ func NewCsatTemplateRepo(db *gorm.DB) *CsatTemplateRepo {
|
||||
return &CsatTemplateRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *CsatTemplateRepo) DB() *gorm.DB { return r.db }
|
||||
|
||||
// Create inserts a new CSAT template.
|
||||
func (r *CsatTemplateRepo) Create(ctx context.Context, template *model.CsatTemplate) error {
|
||||
return r.db.WithContext(ctx).Create(template).Error
|
||||
@@ -35,6 +37,15 @@ func (r *CsatTemplateRepo) FindByInbox(ctx context.Context, inboxID uint) (*mode
|
||||
return &template, nil
|
||||
}
|
||||
|
||||
func (r *CsatTemplateRepo) FindByID(ctx context.Context, id uint) (*model.CsatTemplate, error) {
|
||||
var template model.CsatTemplate
|
||||
err := r.db.WithContext(ctx).First(&template, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &template, nil
|
||||
}
|
||||
|
||||
// FindByInboxAndID retrieves a CSAT template by inbox and primary key.
|
||||
func (r *CsatTemplateRepo) FindByInboxAndID(ctx context.Context, inboxID uint, id uint) (*model.CsatTemplate, error) {
|
||||
var template model.CsatTemplate
|
||||
@@ -53,4 +64,4 @@ func (r *CsatTemplateRepo) Update(ctx context.Context, template *model.CsatTempl
|
||||
// Delete removes a CSAT template by primary key.
|
||||
func (r *CsatTemplateRepo) Delete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(&model.CsatTemplate{}, id).Error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@ 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"
|
||||
)
|
||||
@@ -17,33 +22,79 @@ import (
|
||||
// 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
|
||||
repo *repository.CsatTemplateRepo
|
||||
provider CsatTemplateProvider
|
||||
worker *worker.WorkerPool
|
||||
}
|
||||
|
||||
// NewCsatTemplateService creates a new CsatTemplate service.
|
||||
func NewCsatTemplateService(repo *repository.CsatTemplateRepo) *CsatTemplateService {
|
||||
return &CsatTemplateService{repo: repo}
|
||||
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"`
|
||||
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"`
|
||||
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
|
||||
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.
|
||||
@@ -61,6 +112,40 @@ func (s *CsatTemplateService) ShowTemplateStatus(ctx context.Context, inboxID ui
|
||||
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
|
||||
@@ -82,6 +167,12 @@ func (s *CsatTemplateService) CreateTemplate(ctx context.Context, inboxID uint,
|
||||
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
|
||||
}
|
||||
@@ -95,10 +186,84 @@ func (s *CsatTemplateService) CreateTemplate(ctx context.Context, inboxID uint,
|
||||
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)
|
||||
@@ -129,4 +294,83 @@ func (s *CsatTemplateService) AnalyzeTemplate(ctx context.Context, inboxID uint,
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
)
|
||||
|
||||
const TaskTypeCsatTemplateCreate = "csat:template_create"
|
||||
|
||||
type csatTemplateCreateJob struct {
|
||||
TemplateID uint `json:"template_id"`
|
||||
Request CreateCsatTemplateRequest `json:"request"`
|
||||
}
|
||||
|
||||
var csatTemplateRegistrations sync.Map
|
||||
|
||||
// RegisterCsatTemplateJobs wires Chatwoot WhatsApp/Twilio CSAT template
|
||||
// creation into the durable worker path.
|
||||
func RegisterCsatTemplateJobs(wp *worker.WorkerPool, svc *CsatTemplateService) {
|
||||
if wp == nil || svc == nil {
|
||||
return
|
||||
}
|
||||
if _, loaded := csatTemplateRegistrations.LoadOrStore(wp, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
wp.Register(TaskTypeCsatTemplateCreate, svc.performTemplateCreateJob)
|
||||
}
|
||||
|
||||
func (s *CsatTemplateService) performTemplateCreateJob(ctx context.Context, job *model.BackgroundJob) error {
|
||||
var payload csatTemplateCreateJob
|
||||
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal csat template job: %w", err)
|
||||
}
|
||||
if payload.TemplateID == 0 {
|
||||
return fmt.Errorf("invalid csat template job payload: %#v", payload)
|
||||
}
|
||||
return s.performTemplateCreate(ctx, payload.TemplateID, payload.Request)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
)
|
||||
|
||||
type fakeCsatTemplateProvider struct {
|
||||
calls int
|
||||
result *CsatTemplateProviderResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *fakeCsatTemplateProvider) CreateTemplate(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate, req CreateCsatTemplateRequest) (*CsatTemplateProviderResult, error) {
|
||||
p.calls++
|
||||
if p.err != nil {
|
||||
return nil, p.err
|
||||
}
|
||||
if p.result != nil {
|
||||
return p.result, nil
|
||||
}
|
||||
return &CsatTemplateProviderResult{TemplateExists: true, Status: "PENDING", TemplateName: "csat_survey_test", TemplateID: "tmpl_1", Language: "en"}, nil
|
||||
}
|
||||
|
||||
func (p *fakeCsatTemplateProvider) GetTemplateStatus(ctx context.Context, inbox *model.Inbox, template *model.CsatTemplate) (*CsatTemplateProviderResult, error) {
|
||||
return p.result, p.err
|
||||
}
|
||||
|
||||
func TestCsatTemplateService_CreateTemplateQueuesDurableProviderCreation(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
repo := repository.NewCsatTemplateRepo(db)
|
||||
svc := NewCsatTemplateService(repo)
|
||||
provider := &fakeCsatTemplateProvider{result: &CsatTemplateProviderResult{TemplateExists: true, Status: "PENDING", TemplateName: "csat_survey_1", TemplateID: "tmpl_123", Language: "en"}}
|
||||
svc.SetProvider(provider)
|
||||
now := time.Date(2026, 6, 5, 22, 0, 0, 0, time.UTC)
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }))
|
||||
svc.SetWorkerPool(wp)
|
||||
account := createTestAccount(t, db)
|
||||
inbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeWhatsApp))
|
||||
|
||||
template, err := svc.CreateTemplate(context.Background(), inbox.ID, CreateCsatTemplateRequest{Message: "Please rate our support", ButtonText: "Please rate us", Language: "en"})
|
||||
if err != nil {
|
||||
t.Fatalf("create template: %v", err)
|
||||
}
|
||||
if template.Status != "pending" {
|
||||
t.Fatalf("expected pending before worker, got %s", template.Status)
|
||||
}
|
||||
if provider.calls != 0 {
|
||||
t.Fatalf("expected provider deferred to worker, got %d calls", provider.calls)
|
||||
}
|
||||
|
||||
var jobCount int64
|
||||
if err := db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ? AND status = ?", TaskTypeCsatTemplateCreate, "automation", model.BackgroundJobStatusQueued).Count(&jobCount).Error; err != nil {
|
||||
t.Fatalf("count jobs: %v", err)
|
||||
}
|
||||
if jobCount != 1 {
|
||||
t.Fatalf("expected one csat template job, got %d", jobCount)
|
||||
}
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
if err != nil || !processed {
|
||||
t.Fatalf("process csat template job: processed=%v err=%v", processed, err)
|
||||
}
|
||||
|
||||
var completed model.CsatTemplate
|
||||
if err := db.First(&completed, template.ID).Error; err != nil {
|
||||
t.Fatalf("load completed template: %v", err)
|
||||
}
|
||||
if completed.Status != "PENDING" {
|
||||
t.Fatalf("expected provider status PENDING, got %s", completed.Status)
|
||||
}
|
||||
var reloadedInbox model.Inbox
|
||||
if err := db.First(&reloadedInbox, inbox.ID).Error; err != nil {
|
||||
t.Fatalf("load inbox: %v", err)
|
||||
}
|
||||
if !strings.Contains(reloadedInbox.CsatConfig, "csat_survey_1") || !strings.Contains(reloadedInbox.CsatConfig, "tmpl_123") {
|
||||
t.Fatalf("expected inbox csat_config to include provider template metadata, got %s", reloadedInbox.CsatConfig)
|
||||
}
|
||||
if provider.calls != 1 {
|
||||
t.Fatalf("expected one provider call, got %d", provider.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsatTemplateService_TwilioTemplateStoresContentSid(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
repo := repository.NewCsatTemplateRepo(db)
|
||||
svc := NewCsatTemplateService(repo)
|
||||
svc.SetProvider(&fakeCsatTemplateProvider{result: &CsatTemplateProviderResult{TemplateExists: true, Status: "PENDING", FriendlyName: "csat_survey_2", ContentSID: "HX123", ApprovalSID: "HA123", Language: "en"}})
|
||||
account := createTestAccount(t, db)
|
||||
inbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeTwilioSMS), func(inbox *model.Inbox) {
|
||||
inbox.ChannelConfig = `{"medium":"whatsapp"}`
|
||||
})
|
||||
|
||||
template, err := svc.CreateTemplate(context.Background(), inbox.ID, CreateCsatTemplateRequest{Message: "Rate us over WhatsApp"})
|
||||
if err != nil {
|
||||
t.Fatalf("create twilio template: %v", err)
|
||||
}
|
||||
if template.Status != "PENDING" {
|
||||
t.Fatalf("expected sync provider status PENDING, got %s", template.Status)
|
||||
}
|
||||
var reloadedInbox model.Inbox
|
||||
if err := db.First(&reloadedInbox, inbox.ID).Error; err != nil {
|
||||
t.Fatalf("load inbox: %v", err)
|
||||
}
|
||||
if !strings.Contains(reloadedInbox.CsatConfig, "HX123") || !strings.Contains(reloadedInbox.CsatConfig, "HA123") {
|
||||
t.Fatalf("expected twilio content/approval SIDs in csat_config, got %s", reloadedInbox.CsatConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCsatTemplateService_TemplateJobRetriesProviderFailures(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
repo := repository.NewCsatTemplateRepo(db)
|
||||
svc := NewCsatTemplateService(repo)
|
||||
svc.SetProvider(&fakeCsatTemplateProvider{err: errors.New("provider unavailable")})
|
||||
now := time.Date(2026, 6, 5, 22, 30, 0, 0, time.UTC)
|
||||
wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }), worker.WithBackoff(func(attempt int) time.Duration { return time.Minute }))
|
||||
svc.SetWorkerPool(wp)
|
||||
account := createTestAccount(t, db)
|
||||
inbox := createTestInbox(t, db, account.ID, string(model.InboxChannelTypeWhatsApp))
|
||||
|
||||
template, err := svc.CreateTemplate(context.Background(), inbox.ID, CreateCsatTemplateRequest{Message: "Please rate our support"})
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue template create: %v", err)
|
||||
}
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
if err == nil || !processed {
|
||||
t.Fatalf("expected provider failure to retry, processed=%v err=%v", processed, err)
|
||||
}
|
||||
var job model.BackgroundJob
|
||||
if err := db.Where("job_type = ?", TaskTypeCsatTemplateCreate).First(&job).Error; err != nil {
|
||||
t.Fatalf("load template job: %v", err)
|
||||
}
|
||||
if job.Status != model.BackgroundJobStatusRetrying || job.LastError == "" {
|
||||
t.Fatalf("expected retrying template job with error, got status=%s last_error=%q", job.Status, job.LastError)
|
||||
}
|
||||
var failed model.CsatTemplate
|
||||
if err := db.First(&failed, template.ID).Error; err != nil {
|
||||
t.Fatalf("load failed template: %v", err)
|
||||
}
|
||||
if failed.Status != "failed" {
|
||||
t.Fatalf("expected failed template status, got %s", failed.Status)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func setupServiceTestDB(t *testing.T) *gorm.DB {
|
||||
&model.Tag{},
|
||||
&model.ContactLabel{},
|
||||
&model.ContactExport{},
|
||||
&model.CsatTemplate{},
|
||||
&model.DataImport{},
|
||||
&model.Notification{},
|
||||
&model.BackgroundJob{},
|
||||
|
||||
Reference in New Issue
Block a user