Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
803 lines
24 KiB
Go
803 lines
24 KiB
Go
package automation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"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
|
|
searchIndexer CsatSurveySearchIndexer
|
|
}
|
|
|
|
// CsatSurveySearchIndexer is the narrow Meilisearch synchronization boundary
|
|
// needed by CSAT survey message creation and submission updates.
|
|
type CsatSurveySearchIndexer interface {
|
|
IndexConversation(ctx context.Context, conversation *model.Conversation) error
|
|
IndexMessage(ctx context.Context, message *model.Message) error
|
|
}
|
|
|
|
// NewCsatSurveyService creates a new CsatSurveyService.
|
|
func NewCsatSurveyService(db DBProvider) *CsatSurveyService {
|
|
return &CsatSurveyService{db: db}
|
|
}
|
|
|
|
func (s *CsatSurveyService) SetSearchIndexer(indexer CsatSurveySearchIndexer) {
|
|
s.searchIndexer = indexer
|
|
}
|
|
|
|
// 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
|
|
AgentIDs []uint
|
|
InboxID *uint
|
|
TeamID *uint
|
|
Rating *int
|
|
Since *time.Time
|
|
Until *time.Time
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
// CsatMetrics holds aggregate CSAT metrics.
|
|
// Reference: Chatwoot GET csat_survey_responses/metrics — total_count, ratings_count, total_sent_messages_count.
|
|
type CsatMetrics struct {
|
|
TotalCount int64 `json:"total_count"`
|
|
RatingsCount map[int]int64 `json:"ratings_count"`
|
|
TotalSentMessagesCount int64 `json:"total_sent_messages_count"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// GetByIDForAccount retrieves a CSAT survey response scoped to an account.
|
|
func (s *CsatSurveyService) GetByIDForAccount(ctx context.Context, accountID uint, id uint) (*CsatSurveyResponse, error) {
|
|
var resp CsatSurveyResponse
|
|
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&resp).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 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
|
|
}
|
|
s.indexCsatSearchDocuments(ctx, message, conversation)
|
|
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
|
|
}
|
|
if isTweetConversation(conversation) {
|
|
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
|
|
}
|
|
if !conversationCanReceiveCsatSurvey(ctx, s.db.DB(), conversation, &inbox) {
|
|
message := &model.Message{
|
|
ConversationID: conversation.ID,
|
|
AccountID: conversation.AccountID,
|
|
InboxID: conversation.InboxID,
|
|
Content: csatNotSentDueToMessagingWindowMessage,
|
|
ContentType: "text",
|
|
MessageType: string(model.MessageTypeActivity),
|
|
Status: "sent",
|
|
}
|
|
if err := s.db.DB().WithContext(ctx).Create(message).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
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: string(model.MessageTypeTemplate),
|
|
Status: "sent",
|
|
ContentAttributes: attrs,
|
|
}
|
|
if err := s.db.DB().WithContext(ctx).Create(message).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
s.indexCsatSearchDocuments(ctx, message, conversation)
|
|
return message, nil
|
|
}
|
|
|
|
const csatNotSentDueToMessagingWindowMessage = "CSAT survey not sent due to outgoing message restrictions"
|
|
|
|
func conversationCanReceiveCsatSurvey(ctx context.Context, db *gorm.DB, conversation *model.Conversation, inbox *model.Inbox) bool {
|
|
if conversation == nil || inbox == nil {
|
|
return false
|
|
}
|
|
window := csatMessagingWindow(inbox)
|
|
if window <= 0 {
|
|
return true
|
|
}
|
|
|
|
var lastIncoming model.Message
|
|
err := db.WithContext(ctx).
|
|
Where("conversation_id = ? AND account_id = ? AND message_type = ?", conversation.ID, conversation.AccountID, string(model.MessageTypeIncoming)).
|
|
Order("created_at DESC, id DESC").
|
|
First(&lastIncoming).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return false
|
|
}
|
|
if err != nil {
|
|
applogger.L().Warnf("csat: failed to inspect last incoming message for conversation %d: %v", conversation.ID, err)
|
|
return false
|
|
}
|
|
return time.Now().Before(lastIncoming.CreatedAt.Add(window))
|
|
}
|
|
|
|
func csatMessagingWindow(inbox *model.Inbox) time.Duration {
|
|
if inbox == nil {
|
|
return 0
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(inbox.ChannelType)) {
|
|
case "api", "channel::api":
|
|
return apiAgentReplyWindow(inbox.ChannelConfig)
|
|
case "facebook", "channel::facebookpage", "instagram", "channel::instagram":
|
|
return 24 * time.Hour
|
|
case "tiktok", "channel::tiktok":
|
|
return 48 * time.Hour
|
|
case "whatsapp", "channel::whatsapp":
|
|
return 24 * time.Hour
|
|
case "twilio_sms", "channel::twiliosms":
|
|
if strings.EqualFold(inboxChannelConfigString(inbox.ChannelConfig, "medium"), "whatsapp") {
|
|
return 24 * time.Hour
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isTweetConversation(conversation *model.Conversation) bool {
|
|
if conversation == nil {
|
|
return false
|
|
}
|
|
channelType := strings.ToLower(strings.TrimSpace(conversation.ChannelType))
|
|
if channelType != "twitter" && channelType != "channel::twitterprofile" {
|
|
return false
|
|
}
|
|
attrs := jsonMap(conversation.AdditionalAttributes)
|
|
conversationType, _ := attrs["type"].(string)
|
|
return strings.EqualFold(strings.TrimSpace(conversationType), "tweet")
|
|
}
|
|
|
|
func apiAgentReplyWindow(rawConfig string) time.Duration {
|
|
hours := inboxChannelConfigInt(rawConfig, "additional_attributes", "agent_reply_time_window")
|
|
if hours <= 0 {
|
|
hours = inboxChannelConfigInt(rawConfig, "agent_reply_time_window")
|
|
}
|
|
if hours <= 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(hours) * time.Hour
|
|
}
|
|
|
|
func inboxChannelConfigString(rawConfig, key string) string {
|
|
config := map[string]any{}
|
|
if strings.TrimSpace(rawConfig) == "" {
|
|
return ""
|
|
}
|
|
if err := json.Unmarshal([]byte(rawConfig), &config); err != nil {
|
|
return ""
|
|
}
|
|
value, _ := config[key].(string)
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func inboxChannelConfigInt(rawConfig string, keys ...string) int {
|
|
config := map[string]any{}
|
|
if strings.TrimSpace(rawConfig) == "" {
|
|
return 0
|
|
}
|
|
if err := json.Unmarshal([]byte(rawConfig), &config); err != nil {
|
|
return 0
|
|
}
|
|
var value any = config
|
|
for _, key := range keys {
|
|
current, ok := value.(map[string]any)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
value = current[key]
|
|
}
|
|
parsed, ok := intValue(value)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func (s *CsatSurveyService) indexCsatSearchDocuments(ctx context.Context, message *model.Message, conversation *model.Conversation) {
|
|
if s.searchIndexer == nil {
|
|
return
|
|
}
|
|
if message != nil {
|
|
if err := s.searchIndexer.IndexMessage(ctx, message); err != nil {
|
|
applogger.L().Warnf("search index sync failed for csat message %d: %v", message.ID, err)
|
|
}
|
|
}
|
|
if conversation != nil {
|
|
if err := s.searchIndexer.IndexConversation(ctx, conversation); err != nil {
|
|
applogger.L().Warnf("search index sync failed for csat conversation %d: %v", conversation.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.csatResponsesQuery(ctx, accountID, filter)
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
page := filter.Page
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
pageSize := filter.PageSize
|
|
if pageSize < 0 {
|
|
pageSize = 25
|
|
}
|
|
if pageSize == 0 {
|
|
if err := query.Order("csat_survey_responses.created_at DESC").Find(&responses).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return responses, int(total), nil
|
|
}
|
|
if pageSize < 1 {
|
|
pageSize = 25
|
|
}
|
|
|
|
offset := (page - 1) * pageSize
|
|
if err := query.Order("csat_survey_responses.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 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 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
|
|
}
|
|
}
|
|
if rating, ok := intValue(value["rating"]); ok {
|
|
feedback, _ := value["feedback_message"].(string)
|
|
return rating, feedback, true
|
|
}
|
|
}
|
|
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()))
|
|
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.csatResponsesQuery(ctx, accountID, filter)
|
|
|
|
var total int64
|
|
if err := query.Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
type ratingCount struct {
|
|
Rating int
|
|
Count int64
|
|
}
|
|
var grouped []ratingCount
|
|
if err := query.Select("rating, COUNT(*) as count").Group("rating").Scan(&grouped).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sentMessages := s.db.DB().WithContext(ctx).
|
|
Model(&model.Message{}).
|
|
Where("account_id = ? AND content_type = ?", accountID, "input_csat")
|
|
if filter.Since != nil {
|
|
sentMessages = sentMessages.Where("created_at >= ?", *filter.Since)
|
|
}
|
|
if filter.Until != nil {
|
|
sentMessages = sentMessages.Where("created_at < ?", *filter.Until)
|
|
}
|
|
var sentCount int64
|
|
if err := sentMessages.Count(&sentCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metrics := &CsatMetrics{TotalCount: total, RatingsCount: map[int]int64{}, TotalSentMessagesCount: sentCount}
|
|
for _, row := range grouped {
|
|
metrics.RatingsCount[row.Rating] = row.Count
|
|
}
|
|
return metrics, nil
|
|
}
|
|
|
|
func (s *CsatSurveyService) csatResponsesQuery(ctx context.Context, accountID uint, filter CsatListFilter) *gorm.DB {
|
|
query := s.db.DB().WithContext(ctx).
|
|
Model(&CsatSurveyResponse{}).
|
|
Where("csat_survey_responses.account_id = ?", accountID)
|
|
|
|
if filter.AgentID != nil {
|
|
query = query.Where("csat_survey_responses.assigned_agent_id = ?", *filter.AgentID)
|
|
}
|
|
if len(filter.AgentIDs) > 0 {
|
|
query = query.Where("csat_survey_responses.assigned_agent_id IN ?", filter.AgentIDs)
|
|
}
|
|
if filter.Rating != nil {
|
|
query = query.Where("csat_survey_responses.rating = ?", *filter.Rating)
|
|
}
|
|
if filter.Since != nil {
|
|
query = query.Where("csat_survey_responses.created_at >= ?", *filter.Since)
|
|
}
|
|
if filter.Until != nil {
|
|
query = query.Where("csat_survey_responses.created_at < ?", *filter.Until)
|
|
}
|
|
if filter.InboxID != nil || filter.TeamID != nil {
|
|
query = query.Joins("JOIN conversations ON conversations.id = csat_survey_responses.conversation_id")
|
|
if filter.InboxID != nil {
|
|
query = query.Where("conversations.inbox_id = ?", *filter.InboxID)
|
|
}
|
|
if filter.TeamID != nil {
|
|
query = query.Where("conversations.team_id = ?", *filter.TeamID)
|
|
}
|
|
}
|
|
return query
|
|
}
|
|
|
|
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 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())
|
|
}
|