feat(csat): send surveys on resolved conversations

This commit is contained in:
2026-06-05 08:30:45 +08:00
parent bda5ad590d
commit ef3a90959d
5 changed files with 384 additions and 51 deletions
+58 -42
View File
@@ -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
}
}
@@ -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
}
+154 -4
View File
@@ -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())
@@ -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 != "" {
@@ -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() {