From ef3a90959ddffe5f11aaf4de8b992505af1de7fc Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 08:30:45 +0800 Subject: [PATCH] feat(csat): send surveys on resolved conversations --- internal/automation/csat_survey_listener.go | 100 ++++++----- .../automation/csat_survey_listener_test.go | 152 +++++++++++++++++ internal/automation/csat_survey_service.go | 158 +++++++++++++++++- .../handler/api/v1/csat_survey_handler.go | 6 +- .../api/v1/csat_survey_handler_test.go | 19 ++- 5 files changed, 384 insertions(+), 51 deletions(-) create mode 100644 internal/automation/csat_survey_listener_test.go diff --git a/internal/automation/csat_survey_listener.go b/internal/automation/csat_survey_listener.go index 8ca741b7..34557f7a 100644 --- a/internal/automation/csat_survey_listener.go +++ b/internal/automation/csat_survey_listener.go @@ -4,7 +4,9 @@ import ( "context" "github.com/gochat/gochat/internal/channel" + "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" + "gorm.io/gorm" ) // CsatSurveyListener sends CSAT surveys when conversations are resolved. @@ -25,62 +27,76 @@ func (l *CsatSurveyListener) Name() string { // OnEvent processes incoming channel events. func (l *CsatSurveyListener) OnEvent(ctx context.Context, event *channel.ChannelEvent) error { + if event.Type == channel.EventMessageUpdated { + return l.onMessageUpdated(ctx, event) + } if event.Type != channel.EventConversationResolved { return nil } - accountID, ok := extractUint(event.Data, "account_id") - if !ok { - applogger.L().Warnf("csat_survey_listener: missing account_id in event %s", event.Type) - return nil - } - - conversationID, ok := extractUint(event.Data, "conversation_id") + conversationID, ok := eventUint(event, "conversation_id") if !ok { applogger.L().Warnf("csat_survey_listener: missing conversation_id in event %s", event.Type) return nil } - // TODO: Check if CSAT is enabled for the inbox associated with this conversation. - // The Inbox model currently lacks csat_enabled/csat_message fields. - // When those fields are added, load the conversation's inbox and check inbox.CSATEnabled. - - // Check if a CSAT survey was already sent for this conversation - var count int64 - l.db.DB().WithContext(ctx). - Model(&CsatSurveyResponse{}). - Where("conversation_id = ?", conversationID). - Count(&count) - - if count > 0 { - applogger.L().Debugf("csat_survey_listener: CSAT already sent for conversation %d, skipping", conversationID) - return nil - } - - applogger.L().Infof("csat_survey_listener: creating CSAT survey record for account %d conversation %d", accountID, conversationID) - - // Create a pending CSAT survey response record - // The customer will submit their rating via the public endpoint - survey := &CsatSurveyResponse{ - AccountID: accountID, - ConversationID: conversationID, - Rating: 0, // pending — customer hasn't submitted yet - } - - if err := l.db.DB().WithContext(ctx).Create(survey).Error; err != nil { - applogger.L().Errorf("csat_survey_listener: failed to create CSAT survey for conversation %d: %v", conversationID, err) + message, err := NewCsatSurveyService(l.db).SendSurveyForConversationID(ctx, conversationID) + if err != nil { + applogger.L().Errorf("csat_survey_listener: failed to send CSAT survey for conversation %d: %v", conversationID, err) return err } - - applogger.L().Infof("csat_survey_listener: CSAT survey record %d created for conversation %d", survey.ID, conversationID) - - // TODO: Send CSAT message to the contact via the channel (ActionService.SendCSATSurvey pattern) - // This requires loading the conversation's inbox, contact, and channel to dispatch the message. - // Will be wired when the messaging pipeline is fully integrated. + if message != nil { + applogger.L().Infof("csat_survey_listener: CSAT survey message %d ready for conversation %d", message.ID, conversationID) + } return nil } +func (l *CsatSurveyListener) onMessageUpdated(ctx context.Context, event *channel.ChannelEvent) error { + messageID, ok := eventUint(event, "message_id") + if !ok { + return nil + } + var message model.Message + if err := l.db.DB().WithContext(ctx).First(&message, messageID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return err + } + if message.ContentType != "input_csat" { + return nil + } + var conversation model.Conversation + if err := l.db.DB().WithContext(ctx).First(&conversation, message.ConversationID).Error; err != nil { + return err + } + submittedValues := jsonMap(message.ContentAttributes)["submitted_values"] + if _, _, ok := ExtractCsatSubmittedValues(submittedValues); !ok { + return nil + } + _, err := ApplyCsatSubmission(ctx, l.db.DB(), &message, &conversation, submittedValues) + return err +} + +func eventUint(event *channel.ChannelEvent, key string) (uint, bool) { + switch key { + case "account_id": + if event.AccountID != 0 { + return event.AccountID, true + } + case "conversation_id": + if event.ConversationID != 0 { + return event.ConversationID, true + } + case "message_id": + if value, ok := extractUint(event.Data, key); ok { + return value, true + } + } + return extractUint(event.Data, key) +} + // extractUint extracts a uint value from a map[string]interface{}. func extractUint(data map[string]interface{}, key string) (uint, bool) { v, ok := data[key] @@ -100,4 +116,4 @@ func extractUint(data map[string]interface{}, key string) (uint, bool) { return uint(n), true } return 0, false -} \ No newline at end of file +} diff --git a/internal/automation/csat_survey_listener_test.go b/internal/automation/csat_survey_listener_test.go new file mode 100644 index 00000000..2886ba3c --- /dev/null +++ b/internal/automation/csat_survey_listener_test.go @@ -0,0 +1,152 @@ +package automation + +import ( + "context" + "encoding/json" + "testing" + + "github.com/gochat/gochat/internal/channel" + "github.com/gochat/gochat/internal/model" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +func TestCsatSurveyListener_ResolvedConversationSendsOneSurveyMessage(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, true) + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventConversationResolved, ConversationID: conversation.ID} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected idempotent listener call, got %v", err) + } + + var messages []model.Message + if err := db.Where("conversation_id = ? AND content_type = ?", conversation.ID, "input_csat").Find(&messages).Error; err != nil { + t.Fatalf("failed to load csat messages: %v", err) + } + if len(messages) != 1 { + t.Fatalf("expected exactly one CSAT message, got %d", len(messages)) + } + if messages[0].MessageType != "template" { + t.Fatalf("expected template message type, got %q", messages[0].MessageType) + } + if messages[0].Content != "Rate this chat" { + t.Fatalf("expected inbox CSAT message content, got %q", messages[0].Content) + } + var attrs map[string]any + if err := json.Unmarshal(messages[0].ContentAttributes, &attrs); err != nil { + t.Fatalf("failed to decode content attributes: %v", err) + } + if attrs["display_type"] != "star" { + t.Fatalf("expected star display_type, got %v", attrs["display_type"]) + } + + var responseCount int64 + if err := db.Model(&CsatSurveyResponse{}).Where("conversation_id = ?", conversation.ID).Count(&responseCount).Error; err != nil { + t.Fatalf("failed to count responses: %v", err) + } + if responseCount != 0 { + t.Fatalf("expected no response before customer submits rating, got %d", responseCount) + } +} + +func TestCsatSurveyListener_ResolvedConversationSkipsDisabledInbox(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, _ := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, false) + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventConversationResolved, ConversationID: conversation.ID} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + + var messageCount int64 + if err := db.Model(&model.Message{}).Where("conversation_id = ? AND content_type = ?", conversation.ID, "input_csat").Count(&messageCount).Error; err != nil { + t.Fatalf("failed to count messages: %v", err) + } + if messageCount != 0 { + t.Fatalf("expected no CSAT messages for disabled inbox, got %d", messageCount) + } +} + +func TestCsatSurveyListener_MessageUpdatedBuildsResponse(t *testing.T) { + dbProvider := setupAutomationTestDBProvider(t) + db := dbProvider.DB() + accountID, userID := seedTestAccount(db, t) + conversation := seedCsatListenerConversation(t, db, accountID, true) + conversation.AssigneeID = &userID + if err := db.Save(conversation).Error; err != nil { + t.Fatalf("failed to assign conversation: %v", err) + } + message := &model.Message{ + ConversationID: conversation.ID, + AccountID: conversation.AccountID, + InboxID: conversation.InboxID, + ContentType: "input_csat", + MessageType: "template", + Status: "sent", + ContentAttributes: datatypes.JSON( + `{"submitted_values":{"csat_survey_response":{"rating":5,"feedback_message":"Great"}}}`, + ), + } + if err := db.Create(message).Error; err != nil { + t.Fatalf("failed to create message: %v", err) + } + listener := NewCsatSurveyListener(dbProvider) + event := &channel.ChannelEvent{Type: channel.EventMessageUpdated, Data: map[string]any{"message_id": message.ID}} + + if err := listener.OnEvent(context.Background(), event); err != nil { + t.Fatalf("expected no listener error, got %v", err) + } + + var response CsatSurveyResponse + if err := db.Where("message_id = ?", message.ID).First(&response).Error; err != nil { + t.Fatalf("expected CSAT response, got %v", err) + } + if response.Rating != 5 || response.FeedbackMessage != "Great" { + t.Fatalf("unexpected response payload: rating=%d feedback=%q", response.Rating, response.FeedbackMessage) + } + if response.AssignedAgentID == nil || *response.AssignedAgentID != userID { + t.Fatalf("expected assigned agent %d, got %v", userID, response.AssignedAgentID) + } +} + +func seedCsatListenerConversation(t *testing.T, db *gorm.DB, accountID uint, csatEnabled bool) *model.Conversation { + t.Helper() + contact := &model.Contact{AccountID: accountID, Name: "CSAT Contact"} + if err := db.Create(contact).Error; err != nil { + t.Fatalf("failed to create contact: %v", err) + } + inbox := &model.Inbox{ + AccountID: accountID, + Name: "CSAT Inbox", + ChannelType: "web_widget", + Enabled: true, + CsatSurveyEnabled: csatEnabled, + CsatConfig: `{"display_type":"star","message":"Rate this chat"}`, + AllowMessagesAfterResolved: true, + } + if err := db.Create(inbox).Error; err != nil { + t.Fatalf("failed to create inbox: %v", err) + } + conversation := &model.Conversation{ + AccountID: accountID, + InboxID: inbox.ID, + ContactID: contact.ID, + Status: "resolved", + ChannelType: inbox.ChannelType, + Channel: inbox.ChannelType, + } + if err := db.Create(conversation).Error; err != nil { + t.Fatalf("failed to create conversation: %v", err) + } + return conversation +} diff --git a/internal/automation/csat_survey_service.go b/internal/automation/csat_survey_service.go index 3996680b..03d2f439 100644 --- a/internal/automation/csat_survey_service.go +++ b/internal/automation/csat_survey_service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" "github.com/gochat/gochat/internal/model" @@ -106,7 +107,7 @@ func (s *CsatSurveyService) GetPublicSurveyByConversationUUID(ctx context.Contex return s.publicCsatPayload(ctx, message, conversation, inbox, account) } -func (s *CsatSurveyService) SubmitPublicSurveyByConversationUUID(ctx context.Context, conversationUUID string, submittedValues []map[string]any) (*PublicCsatSurvey, error) { +func (s *CsatSurveyService) SubmitPublicSurveyByConversationUUID(ctx context.Context, conversationUUID string, submittedValues any) (*PublicCsatSurvey, error) { message, conversation, inbox, account, err := s.findPublicCsatMessage(ctx, conversationUUID) if err != nil { return nil, err @@ -120,6 +121,67 @@ func (s *CsatSurveyService) SubmitPublicSurveyByConversationUUID(ctx context.Con return s.publicCsatPayload(ctx, message, conversation, inbox, account) } +func (s *CsatSurveyService) SendSurveyForConversationID(ctx context.Context, conversationID uint) (*model.Message, error) { + var conversation model.Conversation + if err := s.db.DB().WithContext(ctx).First(&conversation, conversationID).Error; err != nil { + return nil, err + } + return s.SendSurveyForConversation(ctx, &conversation) +} + +func (s *CsatSurveyService) SendSurveyForConversation(ctx context.Context, conversation *model.Conversation) (*model.Message, error) { + if conversation == nil { + return nil, errors.New("conversation is required") + } + if conversation.Status != "resolved" { + return nil, nil + } + + var inbox model.Inbox + if err := s.db.DB().WithContext(ctx).First(&inbox, conversation.InboxID).Error; err != nil { + return nil, err + } + if !inbox.CsatSurveyEnabled || !csatAllowedBySurveyRules(inbox.CsatConfig, conversation.Labels) { + return nil, nil + } + + var existing model.Message + err := s.db.DB().WithContext(ctx). + Where("conversation_id = ? AND content_type = ?", conversation.ID, "input_csat"). + Order("id ASC").First(&existing).Error + if err == nil { + return &existing, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + config := csatConfigMap(inbox.CsatConfig) + content, _ := config["message"].(string) + if content == "" { + content = "Please rate this conversation" + } + displayType, _ := config["display_type"].(string) + if displayType == "" { + displayType = "emoji" + } + attrs := mustJSON(map[string]any{"display_type": displayType}) + message := &model.Message{ + ConversationID: conversation.ID, + AccountID: conversation.AccountID, + InboxID: conversation.InboxID, + Content: content, + ContentType: "input_csat", + MessageType: "template", + Status: "sent", + ContentAttributes: attrs, + } + if err := s.db.DB().WithContext(ctx).Create(message).Error; err != nil { + return nil, err + } + return message, nil +} + // ListByAccount retrieves CSAT survey responses for an account with optional filters. func (s *CsatSurveyService) ListByAccount(ctx context.Context, accountID uint, filter CsatListFilter) ([]CsatSurveyResponse, int, error) { var responses []CsatSurveyResponse @@ -189,7 +251,7 @@ func (s *CsatSurveyService) UpdateResponse(ctx context.Context, id uint, rating var ErrCsatSurveyLocked = errors.New("You cannot update the CSAT survey after 14 days") -func ApplyCsatSubmission(ctx context.Context, db *gorm.DB, message *model.Message, conversation *model.Conversation, submittedValues []map[string]any) (*CsatSurveyResponse, error) { +func ApplyCsatSubmission(ctx context.Context, db *gorm.DB, message *model.Message, conversation *model.Conversation, submittedValues any) (*CsatSurveyResponse, error) { if message.ContentType != "input_csat" { return nil, errors.New("invalid CSAT survey message") } @@ -231,8 +293,8 @@ func ApplyCsatSubmission(ctx context.Context, db *gorm.DB, message *model.Messag return &resp, nil } -func ExtractCsatSubmittedValues(submittedValues []map[string]any) (int, string, bool) { - for _, value := range submittedValues { +func ExtractCsatSubmittedValues(submittedValues any) (int, string, bool) { + for _, value := range submittedValueMaps(submittedValues) { if raw, ok := value["csat_survey_response"]; ok { if rating, feedback, ok := extractCsatResponse(raw); ok { return rating, feedback, true @@ -246,6 +308,27 @@ func ExtractCsatSubmittedValues(submittedValues []map[string]any) (int, string, return 0, "", false } +func submittedValueMaps(submittedValues any) []map[string]any { + switch values := submittedValues.(type) { + case nil: + return nil + case map[string]any: + return []map[string]any{values} + case []map[string]any: + return values + case []any: + out := make([]map[string]any, 0, len(values)) + for _, value := range values { + if mapped, ok := value.(map[string]any); ok { + out = append(out, mapped) + } + } + return out + default: + return nil + } +} + func IsCsatSurveyLocked(createdAt, now time.Time) bool { createdDate := dateOnly(createdAt) nowDate := dateOnly(now.In(createdAt.Location())) @@ -482,6 +565,73 @@ func csatConfigMap(raw string) map[string]any { return out } +func csatAllowedBySurveyRules(rawConfig, rawLabels string) bool { + config := csatConfigMap(rawConfig) + rawRules, ok := config["survey_rules"].(map[string]any) + if !ok || len(rawRules) == 0 { + return true + } + ruleValues := stringList(rawRules["values"]) + if len(ruleValues) == 0 { + return true + } + labels := labelSet(rawLabels) + operator, _ := rawRules["operator"].(string) + if operator == "" { + operator = "contains" + } + for _, value := range ruleValues { + _, exists := labels[value] + if operator == "does_not_contain" && exists { + return false + } + if operator != "does_not_contain" && exists { + return true + } + } + return operator == "does_not_contain" +} + +func stringList(raw any) []string { + switch values := raw.(type) { + case []string: + return values + case []any: + out := make([]string, 0, len(values)) + for _, value := range values { + if s, ok := value.(string); ok && s != "" { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func labelSet(raw string) map[string]struct{} { + labels := map[string]struct{}{} + if raw == "" { + return labels + } + var jsonLabels []string + if err := json.Unmarshal([]byte(raw), &jsonLabels); err == nil { + for _, label := range jsonLabels { + if label != "" { + labels[label] = struct{}{} + } + } + return labels + } + for _, label := range strings.Split(raw, ",") { + label = strings.TrimSpace(label) + if label != "" { + labels[label] = struct{}{} + } + } + return labels +} + func dateOnly(value time.Time) time.Time { year, month, day := value.Date() return time.Date(year, month, day, 0, 0, 0, 0, value.Location()) diff --git a/internal/handler/api/v1/csat_survey_handler.go b/internal/handler/api/v1/csat_survey_handler.go index 16ef27cf..ee3d9989 100644 --- a/internal/handler/api/v1/csat_survey_handler.go +++ b/internal/handler/api/v1/csat_survey_handler.go @@ -201,12 +201,12 @@ type publicCsatUpdateBody struct { Rating int `json:"rating"` FeedbackMessage string `json:"feedback_message"` Message struct { - SubmittedValues []map[string]any `json:"submitted_values"` + SubmittedValues any `json:"submitted_values"` } `json:"message"` } -func (b publicCsatUpdateBody) submittedValues() []map[string]any { - if len(b.Message.SubmittedValues) > 0 { +func (b publicCsatUpdateBody) submittedValues() any { + if b.Message.SubmittedValues != nil { return b.Message.SubmittedValues } if b.Rating != 0 || b.FeedbackMessage != "" { diff --git a/internal/handler/api/v1/csat_survey_handler_test.go b/internal/handler/api/v1/csat_survey_handler_test.go index 1d832686..63aac442 100644 --- a/internal/handler/api/v1/csat_survey_handler_test.go +++ b/internal/handler/api/v1/csat_survey_handler_test.go @@ -232,7 +232,7 @@ func (s *CsatSurveyHandlerTestSuite) TestPublicCsatShowAndUpdate_Success() { assert.Equal(s.T(), "Rate this chat", showResp["content"]) assert.Equal(s.T(), "CSAT Inbox", showResp["inbox_name"]) - body := `{"message":{"submitted_values":[{"csat_survey_response":{"rating":4,"feedback_message":"Helpful"}}]}}` + body := `{"message":{"submitted_values":{"csat_survey_response":{"rating":4,"feedback_message":"Helpful"}}}}` wUpdate := httptest.NewRecorder() reqUpdate, _ := http.NewRequest("PATCH", "/public/api/v1/csat_survey/"+conversation.UUID, bytes.NewBufferString(body)) reqUpdate.Header.Set("Content-Type", "application/json") @@ -257,7 +257,22 @@ func (s *CsatSurveyHandlerTestSuite) TestPublicCsatShowAndUpdate_Success() { s.Require().NoError(s.db.First(&storedMessage, message.ID).Error) var attrs map[string]any s.Require().NoError(json.Unmarshal(storedMessage.ContentAttributes, &attrs)) - assert.NotEmpty(s.T(), attrs["submitted_values"]) + submittedValues := attrs["submitted_values"].(map[string]any) + assert.Contains(s.T(), submittedValues, "csat_survey_response") + + secondBody := `{"message":{"submitted_values":{"csat_survey_response":{"rating":2,"feedback_message":"Could be better"}}}}` + wSecondUpdate := httptest.NewRecorder() + reqSecondUpdate, _ := http.NewRequest("PATCH", "/public/api/v1/csat_survey/"+conversation.UUID, bytes.NewBufferString(secondBody)) + reqSecondUpdate.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wSecondUpdate, reqSecondUpdate) + assert.Equal(s.T(), http.StatusOK, wSecondUpdate.Code) + + var count int64 + s.Require().NoError(s.db.Model(&automation.CsatSurveyResponse{}).Where("message_id = ?", message.ID).Count(&count).Error) + assert.Equal(s.T(), int64(1), count) + s.Require().NoError(s.db.Where("message_id = ?", message.ID).First(&stored).Error) + assert.Equal(s.T(), 2, stored.Rating) + assert.Equal(s.T(), "Could be better", stored.FeedbackMessage) } func (s *CsatSurveyHandlerTestSuite) TestPublicCsatUpdate_LockedAfter14Days() {