Files
gochat/internal/automation/csat_survey_service.go
T

455 lines
14 KiB
Go

package automation
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"time"
"github.com/gochat/gochat/internal/model"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// CsatSurveyService provides CRUD + metrics for CSAT survey responses.
// Reference: Chatwoot CsatSurveys::ResponseBuilder + CsatSurveyResponse API
type CsatSurveyService struct {
db DBProvider
}
// NewCsatSurveyService creates a new CsatSurveyService.
func NewCsatSurveyService(db DBProvider) *CsatSurveyService {
return &CsatSurveyService{db: db}
}
// Ready reports whether the service has a DB provider configured.
func (s *CsatSurveyService) Ready() bool {
return s != nil && s.db != nil
}
// DB returns the underlying gorm.DB for direct queries (e.g., CSV export lookups).
func (s *CsatSurveyService) DB() *gorm.DB {
return s.db.DB()
}
// CsatListFilter holds filter parameters for CSAT survey list queries.
// Reference: Chatwoot GET csat_survey_responses — supports pagination, date/agent/inbox/team filters
type CsatListFilter struct {
AgentID *uint
InboxID *uint
TeamID *uint
Since *time.Time
Until *time.Time
Page int
PageSize int
}
// CsatMetrics holds aggregate CSAT metrics.
// Reference: Chatwoot GET csat_survey_responses/metrics — total responses, rating distribution, average
type CsatMetrics struct {
TotalResponses int `json:"total_responses"`
RatingDistribution map[int]int `json:"rating_distribution"` // {1: N, 2: N, ..., 5: N}
AverageRating float64 `json:"average_rating"`
}
type PublicCsatSurvey struct {
ID uint `json:"id"`
CsatSurveyResponse *CsatSurveyResponse `json:"csat_survey_response"`
DisplayType string `json:"display_type"`
Content string `json:"content"`
InboxAvatarURL string `json:"inbox_avatar_url"`
InboxName string `json:"inbox_name"`
Locale string `json:"locale"`
ConversationID uint `json:"conversation_id"`
CreatedAt time.Time `json:"created_at"`
}
// GetByID retrieves a CSAT survey response by ID.
func (s *CsatSurveyService) GetByID(ctx context.Context, id uint) (*CsatSurveyResponse, error) {
var resp CsatSurveyResponse
if err := s.db.DB().WithContext(ctx).First(&resp, id).Error; err != nil {
return nil, err
}
return &resp, nil
}
// GetByConversationUUID retrieves a CSAT survey response by conversation UUID (for public access).
// Reference: Chatwoot GET /public/api/v1/csat_survey/:id — uses conversation UUID
func (s *CsatSurveyService) GetByConversationUUID(ctx context.Context, conversationUUID string) (*CsatSurveyResponse, error) {
var convID uint
if err := s.db.DB().WithContext(ctx).
Model(&ConversationForFilter{}).
Where("uuid = ?", conversationUUID).
Select("id").
First(&convID).Error; err != nil {
return nil, fmt.Errorf("conversation not found: %w", err)
}
var resp CsatSurveyResponse
if err := s.db.DB().WithContext(ctx).
Where("conversation_id = ?", convID).
First(&resp).Error; err != nil {
return nil, err
}
return &resp, nil
}
func (s *CsatSurveyService) GetPublicSurveyByConversationUUID(ctx context.Context, conversationUUID string) (*PublicCsatSurvey, error) {
message, conversation, inbox, account, err := s.findPublicCsatMessage(ctx, conversationUUID)
if err != nil {
return nil, err
}
return s.publicCsatPayload(ctx, message, conversation, inbox, account)
}
func (s *CsatSurveyService) SubmitPublicSurveyByConversationUUID(ctx context.Context, conversationUUID string, submittedValues []map[string]any) (*PublicCsatSurvey, error) {
message, conversation, inbox, account, err := s.findPublicCsatMessage(ctx, conversationUUID)
if err != nil {
return nil, err
}
if IsCsatSurveyLocked(message.CreatedAt, time.Now()) {
return nil, ErrCsatSurveyLocked
}
if _, err := ApplyCsatSubmission(ctx, s.db.DB(), message, conversation, submittedValues); err != nil {
return nil, err
}
return s.publicCsatPayload(ctx, message, conversation, inbox, account)
}
// 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
query := s.db.DB().WithContext(ctx).Where("account_id = ?", accountID)
if filter.AgentID != nil {
query = query.Where("assigned_agent_id = ?", *filter.AgentID)
}
if filter.Since != nil {
query = query.Where("created_at >= ?", *filter.Since)
}
if filter.Until != nil {
query = query.Where("created_at <= ?", *filter.Until)
}
var total int64
if err := query.Model(&CsatSurveyResponse{}).Count(&total).Error; err != nil {
return nil, 0, err
}
page := filter.Page
if page < 1 {
page = 1
}
pageSize := filter.PageSize
if pageSize < 1 {
pageSize = 25
}
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&responses).Error; err != nil {
return nil, 0, err
}
return responses, int(total), nil
}
// Create creates a new CSAT survey response.
// Reference: Chatwoot creates CSAT response when customer submits rating
func (s *CsatSurveyService) Create(ctx context.Context, resp *CsatSurveyResponse) error {
if resp.Rating < 1 || resp.Rating > 5 {
return fmt.Errorf("rating must be between 1 and 5")
}
return s.db.DB().WithContext(ctx).Create(resp).Error
}
// UpdateResponse updates a CSAT survey response rating and feedback (within 14-day window).
// Reference: Chatwoot PUT /public/api/v1/csat_survey/:id — customer can update within 14 days
func (s *CsatSurveyService) UpdateResponse(ctx context.Context, id uint, rating int, feedback string) error {
var resp CsatSurveyResponse
if err := s.db.DB().WithContext(ctx).First(&resp, id).Error; err != nil {
return err
}
// Check 14-day window
if time.Since(resp.CreatedAt) > 14*24*time.Hour {
return fmt.Errorf("CSAT response can only be updated within 14 days")
}
if rating < 1 || rating > 5 {
return fmt.Errorf("rating must be between 1 and 5")
}
return s.db.DB().WithContext(ctx).
Model(&resp).
Updates(map[string]interface{}{
"rating": rating,
"feedback_message": feedback,
}).Error
}
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) {
if message.ContentType != "input_csat" {
return nil, errors.New("invalid CSAT survey message")
}
rating, feedback, ok := ExtractCsatSubmittedValues(submittedValues)
if !ok {
return nil, errors.New("csat rating is required")
}
if rating < 1 || rating > 5 {
return nil, fmt.Errorf("rating must be between 1 and 5")
}
attrs := jsonMap(message.ContentAttributes)
attrs["submitted_values"] = submittedValues
message.ContentAttributes = mustJSON(attrs)
if err := db.WithContext(ctx).Save(message).Error; err != nil {
return nil, err
}
var resp CsatSurveyResponse
messageID := message.ID
err := db.WithContext(ctx).Where("message_id = ?", message.ID).First(&resp).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
resp = CsatSurveyResponse{
AccountID: message.AccountID,
ConversationID: message.ConversationID,
ContactID: conversation.ContactID,
MessageID: &messageID,
AssignedAgentID: conversation.AssigneeID,
}
}
resp.Rating = rating
resp.FeedbackMessage = feedback
if err := db.WithContext(ctx).Save(&resp).Error; err != nil {
return nil, err
}
return &resp, nil
}
func ExtractCsatSubmittedValues(submittedValues []map[string]any) (int, string, bool) {
for _, value := range submittedValues {
if raw, ok := value["csat_survey_response"]; ok {
if rating, feedback, ok := extractCsatResponse(raw); ok {
return rating, feedback, true
}
}
if rating, ok := intValue(value["rating"]); ok {
feedback, _ := value["feedback_message"].(string)
return rating, feedback, true
}
}
return 0, "", false
}
func IsCsatSurveyLocked(createdAt, now time.Time) bool {
createdDate := dateOnly(createdAt)
nowDate := dateOnly(now.In(createdAt.Location()))
return nowDate.Sub(createdDate) > 14*24*time.Hour
}
func (s *CsatSurveyService) findPublicCsatMessage(ctx context.Context, conversationUUID string) (*model.Message, *model.Conversation, *model.Inbox, *model.Account, error) {
var conversation model.Conversation
if err := s.db.DB().WithContext(ctx).Where("uuid = ?", conversationUUID).First(&conversation).Error; err != nil {
return nil, nil, nil, nil, fmt.Errorf("conversation not found: %w", err)
}
var message model.Message
if err := s.db.DB().WithContext(ctx).
Where("conversation_id = ? AND content_type = ?", conversation.ID, "input_csat").
Order("id ASC").First(&message).Error; err != nil {
return nil, nil, nil, nil, fmt.Errorf("csat survey message not found: %w", err)
}
var inbox model.Inbox
if err := s.db.DB().WithContext(ctx).First(&inbox, conversation.InboxID).Error; err != nil {
return nil, nil, nil, nil, err
}
var account model.Account
if err := s.db.DB().WithContext(ctx).First(&account, conversation.AccountID).Error; err != nil {
return nil, nil, nil, nil, err
}
return &message, &conversation, &inbox, &account, nil
}
func (s *CsatSurveyService) publicCsatPayload(ctx context.Context, message *model.Message, conversation *model.Conversation, inbox *model.Inbox, account *model.Account) (*PublicCsatSurvey, error) {
var resp CsatSurveyResponse
var responsePtr *CsatSurveyResponse
if err := s.db.DB().WithContext(ctx).Where("message_id = ?", message.ID).First(&resp).Error; err == nil {
responsePtr = &resp
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
config := csatConfigMap(inbox.CsatConfig)
displayType, _ := config["display_type"].(string)
if displayType == "" {
displayType = "emoji"
}
content, _ := config["message"].(string)
return &PublicCsatSurvey{
ID: message.ID,
CsatSurveyResponse: responsePtr,
DisplayType: displayType,
Content: content,
InboxAvatarURL: inbox.AvatarURL,
InboxName: inbox.Name,
Locale: account.Locale,
ConversationID: message.ConversationID,
CreatedAt: message.CreatedAt,
}, nil
}
// UpdateReviewNotes updates internal review notes on a CSAT response.
// Reference: Chatwoot csat_review_notes update by agent
func (s *CsatSurveyService) UpdateReviewNotes(ctx context.Context, id uint, notes string, updatedBy uint) error {
now := time.Now()
return s.db.DB().WithContext(ctx).
Model(&CsatSurveyResponse{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"csat_review_notes": notes,
"review_notes_updated_by_id": updatedBy,
"review_notes_updated_at": &now,
}).Error
}
// Update updates a CSAT survey response with rating, feedback_message, and/or csat_review_notes.
// Reference: Chatwoot CsatSurveyResponsesController#update
func (s *CsatSurveyService) Update(ctx context.Context, id uint, rating int, feedbackMessage string, csatReviewNotes string) (*CsatSurveyResponse, error) {
var resp CsatSurveyResponse
if err := s.db.DB().WithContext(ctx).First(&resp, id).Error; err != nil {
return nil, err
}
if rating != 0 {
if rating < 1 || rating > 5 {
return nil, fmt.Errorf("rating must be between 1 and 5")
}
resp.Rating = rating
}
if feedbackMessage != "" {
resp.FeedbackMessage = feedbackMessage
}
if csatReviewNotes != "" {
resp.CsatReviewNotes = csatReviewNotes
}
if err := s.db.DB().WithContext(ctx).Save(&resp).Error; err != nil {
return nil, err
}
return &resp, nil
}
// Metrics computes CSAT aggregate metrics for an account.
func (s *CsatSurveyService) Metrics(ctx context.Context, accountID uint, filter CsatListFilter) (*CsatMetrics, error) {
query := s.db.DB().WithContext(ctx).Where("account_id = ?", accountID)
if filter.AgentID != nil {
query = query.Where("assigned_agent_id = ?", *filter.AgentID)
}
if filter.Since != nil {
query = query.Where("created_at >= ?", *filter.Since)
}
if filter.Until != nil {
query = query.Where("created_at <= ?", *filter.Until)
}
var responses []CsatSurveyResponse
if err := query.Find(&responses).Error; err != nil {
return nil, err
}
metrics := &CsatMetrics{
RatingDistribution: map[int]int{1: 0, 2: 0, 3: 0, 4: 0, 5: 0},
}
totalRating := 0
for _, r := range responses {
if r.Rating >= 1 && r.Rating <= 5 {
metrics.RatingDistribution[r.Rating]++
totalRating += r.Rating
}
}
metrics.TotalResponses = len(responses)
if len(responses) > 0 {
metrics.AverageRating = float64(totalRating) / float64(len(responses))
}
return metrics, nil
}
func extractCsatResponse(raw any) (int, string, bool) {
response, ok := raw.(map[string]any)
if !ok {
return 0, "", false
}
rating, ok := intValue(response["rating"])
if !ok {
return 0, "", false
}
feedback, _ := response["feedback_message"].(string)
return rating, feedback, true
}
func intValue(raw any) (int, bool) {
switch value := raw.(type) {
case int:
return value, true
case int64:
return int(value), true
case uint:
return int(value), true
case uint64:
return int(value), true
case float64:
return int(value), true
case json.Number:
parsed, err := value.Int64()
return int(parsed), err == nil
case string:
parsed, err := strconv.Atoi(value)
return parsed, err == nil
default:
return 0, false
}
}
func jsonMap(raw datatypes.JSON) map[string]any {
out := map[string]any{}
if len(raw) == 0 {
return out
}
_ = json.Unmarshal(raw, &out)
if out == nil {
out = map[string]any{}
}
return out
}
func mustJSON(value map[string]any) datatypes.JSON {
if value == nil {
value = map[string]any{}
}
data, err := json.Marshal(value)
if err != nil {
return datatypes.JSON(`{}`)
}
return datatypes.JSON(data)
}
func csatConfigMap(raw string) map[string]any {
out := map[string]any{}
if raw == "" {
return out
}
_ = json.Unmarshal([]byte(raw), &out)
if out == nil {
out = map[string]any{}
}
return out
}
func dateOnly(value time.Time) time.Time {
year, month, day := value.Date()
return time.Date(year, month, day, 0, 0, 0, 0, value.Location())
}