From f441680c76f1950c13efa6437c943327864d0be4 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 08:11:46 +0800 Subject: [PATCH] feat(csat): align account report payloads --- internal/automation/csat_survey_service.go | 120 ++++++++----- .../automation/csat_survey_service_test.go | 61 ++++--- .../handler/api/v1/csat_survey_handler.go | 159 +++++++++++++++--- .../api/v1/csat_survey_handler_test.go | 129 +++++++++++++- 4 files changed, 370 insertions(+), 99 deletions(-) diff --git a/internal/automation/csat_survey_service.go b/internal/automation/csat_survey_service.go index ef35aab3..3996680b 100644 --- a/internal/automation/csat_survey_service.go +++ b/internal/automation/csat_survey_service.go @@ -38,8 +38,10 @@ func (s *CsatSurveyService) DB() *gorm.DB { // 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 @@ -47,11 +49,11 @@ type CsatListFilter struct { } // CsatMetrics holds aggregate CSAT metrics. -// Reference: Chatwoot GET csat_survey_responses/metrics — total responses, rating distribution, average +// Reference: Chatwoot GET csat_survey_responses/metrics — total_count, ratings_count, total_sent_messages_count. 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"` + TotalCount int64 `json:"total_count"` + RatingsCount map[int]int64 `json:"ratings_count"` + TotalSentMessagesCount int64 `json:"total_sent_messages_count"` } type PublicCsatSurvey struct { @@ -121,20 +123,10 @@ func (s *CsatSurveyService) SubmitPublicSurveyByConversationUUID(ctx context.Con // 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) - } + query := s.csatResponsesQuery(ctx, accountID, filter) var total int64 - if err := query.Model(&CsatSurveyResponse{}).Count(&total).Error; err != nil { + if err := query.Count(&total).Error; err != nil { return nil, 0, err } @@ -143,12 +135,21 @@ func (s *CsatSurveyService) ListByAccount(ctx context.Context, accountID uint, f 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("created_at DESC").Offset(offset).Limit(pageSize).Find(&responses).Error; err != nil { + if err := query.Order("csat_survey_responses.created_at DESC").Offset(offset).Limit(pageSize).Find(&responses).Error; err != nil { return nil, 0, err } @@ -341,40 +342,73 @@ func (s *CsatSurveyService) Update(ctx context.Context, id uint, rating int, fee // 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) + query := s.csatResponsesQuery(ctx, accountID, filter) - 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 { + var total int64 + if err := query.Count(&total).Error; err != nil { return nil, err } - metrics := &CsatMetrics{ - RatingDistribution: map[int]int{1: 0, 2: 0, 3: 0, 4: 0, 5: 0}, + 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 } - totalRating := 0 - for _, r := range responses { - if r.Rating >= 1 && r.Rating <= 5 { - metrics.RatingDistribution[r.Rating]++ - totalRating += r.Rating + 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) } } - metrics.TotalResponses = len(responses) - if len(responses) > 0 { - metrics.AverageRating = float64(totalRating) / float64(len(responses)) - } - - return metrics, nil + return query } func extractCsatResponse(raw any) (int, string, bool) { diff --git a/internal/automation/csat_survey_service_test.go b/internal/automation/csat_survey_service_test.go index 768c713a..a368a2e6 100644 --- a/internal/automation/csat_survey_service_test.go +++ b/internal/automation/csat_survey_service_test.go @@ -15,9 +15,9 @@ func TestCsatSurveyService_Create(t *testing.T) { svc := NewCsatSurveyService(dbProvider) resp := &CsatSurveyResponse{ - AccountID: accountID, - ConversationID: convID, - ContactID: contactID, + AccountID: accountID, + ConversationID: convID, + ContactID: contactID, AssignedAgentID: &userID, Rating: 4, FeedbackMessage: "Good service", @@ -45,7 +45,7 @@ func TestCsatSurveyService_Create_InvalidRating(t *testing.T) { respLow := &CsatSurveyResponse{ AccountID: accountID, ConversationID: convID, - Rating: 0, + Rating: 0, } err := svc.Create(context.Background(), respLow) if err == nil { @@ -56,7 +56,7 @@ func TestCsatSurveyService_Create_InvalidRating(t *testing.T) { respHigh := &CsatSurveyResponse{ AccountID: accountID, ConversationID: convID, - Rating: 6, + Rating: 6, } err = svc.Create(context.Background(), respHigh) if err == nil { @@ -74,9 +74,9 @@ func TestCsatSurveyService_GetByID(t *testing.T) { svc := NewCsatSurveyService(dbProvider) resp := &CsatSurveyResponse{ - AccountID: accountID, - ConversationID: convID, - ContactID: contactID, + AccountID: accountID, + ConversationID: convID, + ContactID: contactID, AssignedAgentID: &userID, Rating: 5, FeedbackMessage: "Excellent!", @@ -119,9 +119,9 @@ func TestCsatSurveyService_ListByAccount(t *testing.T) { // Create 3 CSAT responses for i := 1; i <= 3; i++ { resp := &CsatSurveyResponse{ - AccountID: accountID, - ConversationID: convID, - ContactID: contactID, + AccountID: accountID, + ConversationID: convID, + ContactID: contactID, AssignedAgentID: &userID, Rating: i, } @@ -152,9 +152,9 @@ func TestCsatSurveyService_ListByAccount_WithFilters(t *testing.T) { svc := NewCsatSurveyService(dbProvider) resp := &CsatSurveyResponse{ - AccountID: accountID, - ConversationID: convID, - ContactID: contactID, + AccountID: accountID, + ConversationID: convID, + ContactID: contactID, AssignedAgentID: &userID, Rating: 3, } @@ -189,9 +189,9 @@ func TestCsatSurveyService_UpdateResponse(t *testing.T) { svc := NewCsatSurveyService(dbProvider) resp := &CsatSurveyResponse{ - AccountID: accountID, - ConversationID: convID, - ContactID: contactID, + AccountID: accountID, + ConversationID: convID, + ContactID: contactID, AssignedAgentID: &userID, Rating: 3, FeedbackMessage: "Okay", @@ -226,7 +226,7 @@ func TestCsatSurveyService_UpdateResponse_InvalidRating(t *testing.T) { resp := &CsatSurveyResponse{ AccountID: accountID, ConversationID: convID, - Rating: 3, + Rating: 3, } if err := svc.Create(context.Background(), resp); err != nil { t.Fatalf("setup: %v", err) @@ -248,9 +248,9 @@ func TestCsatSurveyService_UpdateReviewNotes(t *testing.T) { svc := NewCsatSurveyService(dbProvider) resp := &CsatSurveyResponse{ - AccountID: accountID, - ConversationID: convID, - ContactID: contactID, + AccountID: accountID, + ConversationID: convID, + ContactID: contactID, AssignedAgentID: &userID, Rating: 4, } @@ -281,8 +281,8 @@ func TestCsatSurveyService_Metrics(t *testing.T) { // Create responses with ratings 1,3,5 for _, rating := range []int{1, 3, 5} { resp := &CsatSurveyResponse{ - AccountID: accountID, - ConversationID: convID, + AccountID: accountID, + ConversationID: convID, AssignedAgentID: &userID, Rating: rating, } @@ -295,14 +295,13 @@ func TestCsatSurveyService_Metrics(t *testing.T) { if err != nil { t.Fatalf("expected no error computing metrics, got: %v", err) } - if metrics.TotalResponses != 3 { - t.Fatalf("expected 3 total responses, got %d", metrics.TotalResponses) + if metrics.TotalCount != 3 { + t.Fatalf("expected 3 total responses, got %d", metrics.TotalCount) } - expectedAvg := float64(1+3+5) / 3.0 - if metrics.AverageRating != expectedAvg { - t.Fatalf("expected average rating %.2f, got %.2f", expectedAvg, metrics.AverageRating) + if metrics.TotalSentMessagesCount != 0 { + t.Fatalf("expected 0 sent CSAT messages, got %d", metrics.TotalSentMessagesCount) } - if metrics.RatingDistribution[1] != 1 || metrics.RatingDistribution[3] != 1 || metrics.RatingDistribution[5] != 1 { - t.Fatalf("expected distribution {1:1, 3:1, 5:1}, got %v", metrics.RatingDistribution) + if metrics.RatingsCount[1] != 1 || metrics.RatingsCount[3] != 1 || metrics.RatingsCount[5] != 1 { + t.Fatalf("expected distribution {1:1, 3:1, 5:1}, got %v", metrics.RatingsCount) } -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/csat_survey_handler.go b/internal/handler/api/v1/csat_survey_handler.go index 34610fb7..16ef27cf 100644 --- a/internal/handler/api/v1/csat_survey_handler.go +++ b/internal/handler/api/v1/csat_survey_handler.go @@ -1,6 +1,7 @@ package v1 import ( + "context" "errors" "fmt" "net/http" @@ -36,20 +37,18 @@ func (h *CsatSurveyHandler) List(c *gin.Context) { filter := buildCsatFilter(c) - responses, total, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, filter) + responses, _, svcErr := h.svc.ListByAccount(c.Request.Context(), accountID, filter) if svcErr != nil { handleServiceError(c, svcErr) return } - c.JSON(http.StatusOK, gin.H{ - "csat_survey_responses": responses, - "meta": gin.H{ - "count": total, - "page": filter.Page, - "per_page": filter.PageSize, - }, - }) + payload := make([]map[string]any, 0, len(responses)) + for i := range responses { + payload = append(payload, h.serializeCsatSurveyResponse(c.Request.Context(), &responses[i])) + } + + c.JSON(http.StatusOK, payload) } // Metrics computes aggregated CSAT statistics for an account. @@ -69,7 +68,7 @@ func (h *CsatSurveyHandler) Metrics(c *gin.Context) { return } - response.OK(c, metrics) + c.JSON(http.StatusOK, metrics) } // UpdateReviewNotes updates the review notes on a CSAT survey response. @@ -85,22 +84,28 @@ func (h *CsatSurveyHandler) UpdateReviewNotes(c *gin.Context) { var body struct { CsatReviewNotes string `json:"csat_review_notes"` + ReviewNotes string `json:"review_notes"` } if err := c.ShouldBindJSON(&body); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } - if svcErr := h.svc.UpdateReviewNotes(c.Request.Context(), id, body.CsatReviewNotes, userID); svcErr != nil { + notes := body.CsatReviewNotes + if notes == "" { + notes = body.ReviewNotes + } + if svcErr := h.svc.UpdateReviewNotes(c.Request.Context(), id, notes, userID); svcErr != nil { handleServiceError(c, svcErr) return } - response.OK(c, gin.H{ - "id": id, - "csat_review_notes": body.CsatReviewNotes, - "updated_by_id": userID, - }) + resp, svcErr := h.svc.GetByID(c.Request.Context(), id) + if svcErr != nil { + handleServiceError(c, svcErr) + return + } + c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp)) } // Update updates a CSAT survey response (rating, feedback, review_notes). @@ -116,6 +121,7 @@ func (h *CsatSurveyHandler) Update(c *gin.Context) { Rating int `json:"rating"` FeedbackMessage string `json:"feedback_message"` CsatReviewNotes string `json:"csat_review_notes"` + ReviewNotes string `json:"review_notes"` } if err := c.ShouldBindJSON(&body); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) @@ -126,13 +132,17 @@ func (h *CsatSurveyHandler) Update(c *gin.Context) { return } - resp, svcErr := h.svc.Update(c.Request.Context(), id, body.Rating, body.FeedbackMessage, body.CsatReviewNotes) + notes := body.CsatReviewNotes + if notes == "" { + notes = body.ReviewNotes + } + resp, svcErr := h.svc.Update(c.Request.Context(), id, body.Rating, body.FeedbackMessage, notes) if svcErr != nil { handleServiceError(c, svcErr) return } - response.OK(c, resp) + c.JSON(http.StatusOK, h.serializeCsatSurveyResponse(c.Request.Context(), resp)) } // PublicGet retrieves a CSAT survey response by conversation UUID (no auth required). @@ -221,18 +231,36 @@ func buildCsatFilter(c *gin.Context) automation.CsatListFilter { if v := c.Query("agent_id"); v != "" { filter.AgentID = csatUintPtr(csatParseUint(v)) } + filter.AgentIDs = csatUintList(c.QueryArray("user_ids[]")) + if len(filter.AgentIDs) == 0 { + filter.AgentIDs = csatUintList(c.QueryArray("user_ids")) + } + if len(filter.AgentIDs) == 0 && c.Query("user_ids") != "" { + filter.AgentIDs = csatUintCSV(c.Query("user_ids")) + } + if v := c.Query("inbox_id"); v != "" { + filter.InboxID = csatUintPtr(csatParseUint(v)) + } + if v := c.Query("team_id"); v != "" { + filter.TeamID = csatUintPtr(csatParseUint(v)) + } + if v := c.Query("rating"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + filter.Rating = &n + } + } - // since filter (RFC3339 timestamp) + // since filter (Chatwoot frontend sends Unix seconds; keep RFC3339 compatibility for local callers) if v := c.Query("since"); v != "" { - t, err := time.Parse(time.RFC3339, v) + t, err := parseCsatQueryTime(v) if err == nil { filter.Since = &t } } - // until filter (RFC3339 timestamp) + // until filter (Chatwoot frontend sends Unix seconds; keep RFC3339 compatibility for local callers) if v := c.Query("until"); v != "" { - t, err := time.Parse(time.RFC3339, v) + t, err := parseCsatQueryTime(v) if err == nil { filter.Until = &t } @@ -276,6 +304,93 @@ func csatUintPtr(v uint) *uint { return &v } +func csatUintList(values []string) []uint { + ids := make([]uint, 0, len(values)) + for _, value := range values { + if value == "" { + continue + } + ids = append(ids, csatUintCSV(value)...) + } + return ids +} + +func csatUintCSV(value string) []uint { + parts := strings.Split(value, ",") + ids := make([]uint, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if parsed, err := csatParseUintFull(part); err == nil && parsed > 0 { + ids = append(ids, parsed) + } + } + return ids +} + +func parseCsatQueryTime(value string) (time.Time, error) { + if unix, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.Unix(unix, 0), nil + } + return time.Parse(time.RFC3339, value) +} + +func (h *CsatSurveyHandler) serializeCsatSurveyResponse(ctx context.Context, csat *automation.CsatSurveyResponse) map[string]any { + if csat == nil { + return nil + } + conversationID := uint(0) + var conversation model.Conversation + if err := h.svc.DB().WithContext(ctx).First(&conversation, csat.ConversationID).Error; err == nil { + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + conversationID = *conversation.DisplayID + } else { + conversationID = conversation.ID + } + } + + payload := map[string]any{ + "id": csat.ID, + "rating": csat.Rating, + "feedback_message": csat.FeedbackMessage, + "csat_review_notes": csat.CsatReviewNotes, + "review_notes_updated_at": unixPtr(csat.ReviewNotesUpdatedAt), + "account_id": csat.AccountID, + "message_id": csat.MessageID, + "conversation_id": conversationID, + "created_at": csat.CreatedAt.Unix(), + } + + if csat.ReviewNotesUpdatedByID != nil && *csat.ReviewNotesUpdatedByID != 0 { + var reviewer model.User + if err := h.svc.DB().WithContext(ctx).First(&reviewer, *csat.ReviewNotesUpdatedByID).Error; err == nil { + payload["review_notes_updated_by"] = map[string]any{"id": reviewer.ID, "name": reviewer.Name} + } + } + if csat.ContactID != 0 { + var contact model.Contact + if err := h.svc.DB().WithContext(ctx).First(&contact, csat.ContactID).Error; err == nil { + payload["contact"] = serializeContact(&contact) + } + } + if csat.AssignedAgentID != nil && *csat.AssignedAgentID != 0 { + var agent model.User + if err := h.svc.DB().WithContext(ctx).First(&agent, *csat.AssignedAgentID).Error; err == nil { + payload["assigned_agent"] = serializeUser(&agent, csat.AccountID) + } + } + return payload +} + +func unixPtr(value *time.Time) any { + if value == nil { + return nil + } + return value.Unix() +} + // Download exports CSAT survey responses as CSV. // GET /api/v1/accounts/:account_id/csat_survey_responses/download // Reference: Chatwoot csat_survey_responses_controller#download — CSV format matches Chatwoot template diff --git a/internal/handler/api/v1/csat_survey_handler_test.go b/internal/handler/api/v1/csat_survey_handler_test.go index e10e901d..1d832686 100644 --- a/internal/handler/api/v1/csat_survey_handler_test.go +++ b/internal/handler/api/v1/csat_survey_handler_test.go @@ -43,6 +43,8 @@ func (s *CsatSurveyHandlerTestSuite) SetupSuite() { s.Require().NoError(err) s.Require().NoError(db.AutoMigrate( &model.Account{}, + &model.User{}, + &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, @@ -65,6 +67,8 @@ func (s *CsatSurveyHandlerTestSuite) SetupTest() { s.db.Exec("DELETE FROM conversations") s.db.Exec("DELETE FROM contacts") s.db.Exec("DELETE FROM inboxes") + s.db.Exec("DELETE FROM account_users") + s.db.Exec("DELETE FROM users") } func (s *CsatSurveyHandlerTestSuite) TearDownSuite() { @@ -100,13 +104,78 @@ func (s *CsatSurveyHandlerTestSuite) TestMetrics_Success() { assert.Equal(s.T(), http.StatusOK, w.Code) } +func (s *CsatSurveyHandlerTestSuite) TestList_ChatwootPayloadAndFilters() { + createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second) + agent, reviewer, contact, conversation, message := s.seedAccountCsatResponseGraph(createdAt, 5) + otherContact := &model.Contact{AccountID: s.account.ID, Name: "Other Contact"} + s.Require().NoError(s.db.Create(otherContact).Error) + otherInbox := &model.Inbox{AccountID: s.account.ID, Name: "Other Inbox", ChannelType: "web_widget", Enabled: true} + s.Require().NoError(s.db.Create(otherInbox).Error) + otherConversation := &model.Conversation{AccountID: s.account.ID, InboxID: otherInbox.ID, ContactID: otherContact.ID, Status: "resolved", ChannelType: "web_widget", Channel: "web_widget"} + s.Require().NoError(s.db.Create(otherConversation).Error) + s.Require().NoError(s.db.Create(&automation.CsatSurveyResponse{AccountID: s.account.ID, ConversationID: otherConversation.ID, ContactID: otherContact.ID, Rating: 3}).Error) + + r := gin.New() + r.GET("/api/v1/accounts/:account_id/csat_survey_responses", s.handler.List) + + teamID := *conversation.TeamID + url := fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses?since=%d&until=%d&user_ids=%d&inbox_id=%d&team_id=%d&rating=5", + s.account.ID, createdAt.Add(-time.Hour).Unix(), createdAt.Add(time.Hour).Unix(), agent.ID, conversation.InboxID, teamID) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", url, nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload []map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + s.Require().Len(payload, 1) + item := payload[0] + assert.Equal(s.T(), float64(5), item["rating"]) + assert.Equal(s.T(), "Great", item["feedback_message"]) + assert.Equal(s.T(), "Needs follow up", item["csat_review_notes"]) + assert.Equal(s.T(), float64(42), item["conversation_id"]) + assert.Equal(s.T(), float64(message.ID), item["message_id"]) + assert.Equal(s.T(), float64(createdAt.Unix()), item["created_at"]) + + contactPayload := item["contact"].(map[string]any) + assert.Equal(s.T(), contact.Name, contactPayload["name"]) + assert.Equal(s.T(), contact.Email, contactPayload["email"]) + agentPayload := item["assigned_agent"].(map[string]any) + assert.Equal(s.T(), agent.Email, agentPayload["email"]) + reviewerPayload := item["review_notes_updated_by"].(map[string]any) + assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"]) +} + +func (s *CsatSurveyHandlerTestSuite) TestMetrics_ChatwootPayloadAndFilters() { + createdAt := time.Now().Add(-2 * time.Hour).Truncate(time.Second) + agent, _, _, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5) + + r := gin.New() + r.GET("/api/v1/accounts/:account_id/csat_survey_responses/metrics", s.handler.Metrics) + + url := fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/metrics?since=%d&until=%d&user_ids=%d&inbox_id=%d&rating=5", + s.account.ID, createdAt.Add(-time.Hour).Unix(), createdAt.Add(time.Hour).Unix(), agent.ID, conversation.InboxID) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", url, nil) + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), float64(1), payload["total_count"]) + assert.Equal(s.T(), float64(1), payload["total_sent_messages_count"]) + ratings := payload["ratings_count"].(map[string]any) + assert.Equal(s.T(), float64(1), ratings["5"]) +} + func (s *CsatSurveyHandlerTestSuite) TestUpdateReviewNotes_Success() { - survey := &automation.CsatSurveyResponse{AccountID: s.account.ID, ConversationID: 1, Rating: 5} - s.Require().NoError(s.db.Create(survey).Error) + _, reviewer, _, _, _ := s.seedAccountCsatResponseGraph(time.Now().Add(-time.Hour), 5) + var survey automation.CsatSurveyResponse + s.Require().NoError(s.db.First(&survey).Error) r := gin.New() r.PUT("/api/v1/accounts/:account_id/csats/:id/review_notes", func(c *gin.Context) { - c.Set("user_id", float64(1)) + c.Set("user_id", float64(reviewer.ID)) s.handler.UpdateReviewNotes(c) }) @@ -117,6 +186,11 @@ func (s *CsatSurveyHandlerTestSuite) TestUpdateReviewNotes_Success() { r.ServeHTTP(w, req) assert.Equal(s.T(), http.StatusOK, w.Code) + var payload map[string]any + s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload)) + assert.Equal(s.T(), "good conversation", payload["csat_review_notes"]) + reviewerPayload := payload["review_notes_updated_by"].(map[string]any) + assert.Equal(s.T(), reviewer.Name, reviewerPayload["name"]) } func (s *CsatSurveyHandlerTestSuite) TestUpdate_Success() { @@ -249,3 +323,52 @@ func (s *CsatSurveyHandlerTestSuite) seedPublicCsatSurvey(createdAt time.Time) ( s.Require().NoError(s.db.First(message, message.ID).Error) return conversation, message } + +func (s *CsatSurveyHandlerTestSuite) seedAccountCsatResponseGraph(createdAt time.Time, rating int) (*model.User, *model.User, *model.Contact, *model.Conversation, *model.Message) { + agent := &model.User{AccountID: s.account.ID, Name: "CSAT Agent", Email: "csat-agent@example.com", Role: "agent", Active: true} + reviewer := &model.User{AccountID: s.account.ID, Name: "CSAT Reviewer", Email: "csat-reviewer@example.com", Role: "administrator", Active: true} + s.Require().NoError(s.db.Create(agent).Error) + s.Require().NoError(s.db.Create(reviewer).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.account.ID, UserID: agent.ID, Role: "agent"}).Error) + s.Require().NoError(s.db.Create(&model.AccountUser{AccountID: s.account.ID, UserID: reviewer.ID, Role: "administrator"}).Error) + + contact := &model.Contact{AccountID: s.account.ID, Name: "CSAT Contact", Email: "csat@example.com", PhoneNumber: "+15550000"} + s.Require().NoError(s.db.Create(contact).Error) + inbox := &model.Inbox{AccountID: s.account.ID, Name: "CSAT Inbox", ChannelType: "web_widget", Enabled: true} + s.Require().NoError(s.db.Create(inbox).Error) + displayID := uint(42) + teamID := uint(7) + conversation := &model.Conversation{ + AccountID: s.account.ID, + DisplayID: &displayID, + InboxID: inbox.ID, + ContactID: contact.ID, + AssigneeID: &agent.ID, + TeamID: &teamID, + Status: "resolved", + ChannelType: "web_widget", + Channel: "web_widget", + } + s.Require().NoError(s.db.Create(conversation).Error) + message := &model.Message{ConversationID: conversation.ID, AccountID: s.account.ID, InboxID: inbox.ID, ContentType: "input_csat", MessageType: "outgoing", Content: "Rate this"} + s.Require().NoError(s.db.Create(message).Error) + s.Require().NoError(s.db.Model(message).Updates(map[string]any{"created_at": createdAt, "updated_at": createdAt}).Error) + s.Require().NoError(s.db.First(message, message.ID).Error) + reviewerID := reviewer.ID + updatedAt := createdAt.Add(30 * time.Minute) + response := &automation.CsatSurveyResponse{ + AccountID: s.account.ID, + ConversationID: conversation.ID, + ContactID: contact.ID, + MessageID: &message.ID, + AssignedAgentID: &agent.ID, + Rating: rating, + FeedbackMessage: "Great", + CsatReviewNotes: "Needs follow up", + ReviewNotesUpdatedByID: &reviewerID, + ReviewNotesUpdatedAt: &updatedAt, + } + s.Require().NoError(s.db.Create(response).Error) + s.Require().NoError(s.db.Model(response).Updates(map[string]any{"created_at": createdAt, "updated_at": createdAt}).Error) + return agent, reviewer, contact, conversation, message +}