From b36cf079dbcf045ccd67fca0fa471e7100b1ade4 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 08:40:21 +0800 Subject: [PATCH] feat(csat): align report download csv --- .../handler/api/v1/csat_survey_handler.go | 107 ++++++++++++++---- .../api/v1/csat_survey_handler_test.go | 43 +++++++ 2 files changed, 127 insertions(+), 23 deletions(-) diff --git a/internal/handler/api/v1/csat_survey_handler.go b/internal/handler/api/v1/csat_survey_handler.go index ee3d9989..c3d29885 100644 --- a/internal/handler/api/v1/csat_survey_handler.go +++ b/internal/handler/api/v1/csat_survey_handler.go @@ -2,6 +2,7 @@ package v1 import ( "context" + "encoding/csv" "errors" "fmt" "net/http" @@ -411,58 +412,118 @@ func (h *CsatSurveyHandler) Download(c *gin.Context) { return } - // Chatwoot CSV columns: agent_name, rating, feedback, contact_name, contact_email, - // contact_phone, conversation_link, recorded_at (+ review_notes for enterprise) - csv := "Agent Name,Rating,Feedback Message,Contact Name,Contact Email,Contact Phone Number,Conversation Link,Recorded At\n" + c.Header("Content-Type", "text/csv") + c.Header("Content-Disposition", "attachment; filename=csat_report.csv") + + writer := csv.NewWriter(c.Writer) + header := []string{ + "Agent Name", + "Rating", + "Feedback Comment", + "Contact Name", + "Contact Email Address", + "Contact Phone Number", + "Link to the conversation", + "Recorded date", + "Review Notes", + } + if err := writer.Write(header); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to write CSV header") + return + } + for _, r := range responses { agentName := "" if r.AssignedAgentID != nil { - // Lookup agent name via DB var agent model.User - if err := h.svc.DB().Where("id = ?", *r.AssignedAgentID).First(&agent).Error; err == nil { + if err := h.svc.DB().WithContext(c.Request.Context()).Where("id = ?", *r.AssignedAgentID).First(&agent).Error; err == nil { agentName = fmt.Sprintf("%s (%s)", agent.Name, agent.Email) } } - // Lookup contact details var contact model.Contact contactName := "" contactEmail := "" contactPhone := "" - if err := h.svc.DB().Where("id = ?", r.ContactID).First(&contact).Error; err == nil { + if err := h.svc.DB().WithContext(c.Request.Context()).Where("id = ?", r.ContactID).First(&contact).Error; err == nil { contactName = contact.Name contactEmail = contact.Email contactPhone = contact.PhoneNumber } - // Lookup conversation display_id for link var conv model.Conversation conversationLink := "" - if err := h.svc.DB().Where("id = ?", r.ConversationID).First(&conv).Error; err == nil { - // Chatwoot format: /app/accounts/{account_id}/conversations/{display_id} - conversationLink = fmt.Sprintf("/app/accounts/%d/conversations/%d", accountID, conv.DisplayID) + if err := h.svc.DB().WithContext(c.Request.Context()).Where("id = ?", r.ConversationID).First(&conv).Error; err == nil { + conversationLink = csatConversationURL(c.Request, accountID, &conv) } - csv += fmt.Sprintf("\"%s\",%d,\"%s\",\"%s\",\"%s\",\"%s\",\"%s\",\"%s\"\n", + record := []string{ agentName, - r.Rating, - sanitizeCSV(r.FeedbackMessage), + strconv.Itoa(r.Rating), + r.FeedbackMessage, contactName, contactEmail, contactPhone, conversationLink, r.CreatedAt.Format(time.RFC3339), - ) + r.CsatReviewNotes, + } + if err := writer.Write(record); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to write CSV row") + return + } } - c.Header("Content-Type", "text/csv") - c.Header("Content-Disposition", "attachment; filename=csat_report.csv") - c.String(http.StatusOK, csv) + if period := csatReportPeriod(c); period != "" { + if err := writer.Write([]string{period}); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to write CSV period") + return + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to write CSV") + return + } } -// sanitizeCSV escapes double quotes and newlines for CSV safety. -func sanitizeCSV(s string) string { - s = strings.ReplaceAll(s, "\"", "\"\"") - s = strings.ReplaceAll(s, "\n", " ") - return s +func csatConversationURL(req *http.Request, accountID uint, conversation *model.Conversation) string { + if conversation == nil { + return "" + } + displayID := conversation.ID + if conversation.DisplayID != nil && *conversation.DisplayID != 0 { + displayID = *conversation.DisplayID + } + path := fmt.Sprintf("/app/accounts/%d/conversations/%d", accountID, displayID) + if req == nil || req.Host == "" { + return path + } + scheme := req.Header.Get("X-Forwarded-Proto") + if scheme == "" { + if req.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + return fmt.Sprintf("%s://%s%s", scheme, req.Host, path) +} + +func csatReportPeriod(c *gin.Context) string { + sinceRaw := c.Query("since") + untilRaw := c.Query("until") + if sinceRaw == "" || untilRaw == "" { + return "" + } + since, err := parseCsatQueryTime(sinceRaw) + if err != nil { + return "" + } + until, err := parseCsatQueryTime(untilRaw) + if err != nil { + return "" + } + return fmt.Sprintf("Reporting period %s to %s", since.Format("2006-01-02"), until.Format("2006-01-02")) } diff --git a/internal/handler/api/v1/csat_survey_handler_test.go b/internal/handler/api/v1/csat_survey_handler_test.go index 63aac442..d6fd2b39 100644 --- a/internal/handler/api/v1/csat_survey_handler_test.go +++ b/internal/handler/api/v1/csat_survey_handler_test.go @@ -2,10 +2,12 @@ package v1 import ( "bytes" + "encoding/csv" "encoding/json" "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -168,6 +170,47 @@ func (s *CsatSurveyHandlerTestSuite) TestMetrics_ChatwootPayloadAndFilters() { assert.Equal(s.T(), float64(1), ratings["5"]) } +func (s *CsatSurveyHandlerTestSuite) TestDownload_ChatwootCSVAndFilters() { + createdAt := time.Date(2026, 6, 5, 10, 30, 0, 0, time.UTC) + agent, _, contact, conversation, _ := s.seedAccountCsatResponseGraph(createdAt, 5) + otherContact := &model.Contact{AccountID: s.account.ID, Name: "Other Contact"} + s.Require().NoError(s.db.Create(otherContact).Error) + otherConversation := &model.Conversation{AccountID: s.account.ID, InboxID: conversation.InboxID, 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/download", s.handler.Download) + since := int64(0) + until := int64(32503680000) + url := fmt.Sprintf("/api/v1/accounts/%d/csat_survey_responses/download?since=%d&until=%d&user_ids=%d&inbox_id=%d&team_id=%d&rating=5", + s.account.ID, since, until, agent.ID, conversation.InboxID, *conversation.TeamID) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", url, nil) + req.Host = "app.example.test" + req.Header.Set("X-Forwarded-Proto", "https") + r.ServeHTTP(w, req) + + assert.Equal(s.T(), http.StatusOK, w.Code) + assert.Equal(s.T(), "attachment; filename=csat_report.csv", w.Header().Get("Content-Disposition")) + reader := csv.NewReader(strings.NewReader(w.Body.String())) + reader.FieldsPerRecord = -1 + rows, err := reader.ReadAll() + s.Require().NoError(err) + s.Require().Len(rows, 3) + assert.Equal(s.T(), []string{"Agent Name", "Rating", "Feedback Comment", "Contact Name", "Contact Email Address", "Contact Phone Number", "Link to the conversation", "Recorded date", "Review Notes"}, rows[0]) + assert.Equal(s.T(), "CSAT Agent (csat-agent@example.com)", rows[1][0]) + assert.Equal(s.T(), "5", rows[1][1]) + assert.Equal(s.T(), "Great", rows[1][2]) + assert.Equal(s.T(), contact.Name, rows[1][3]) + assert.Equal(s.T(), contact.Email, rows[1][4]) + assert.Equal(s.T(), contact.PhoneNumber, rows[1][5]) + assert.Equal(s.T(), fmt.Sprintf("https://app.example.test/app/accounts/%d/conversations/42", s.account.ID), rows[1][6]) + assert.Equal(s.T(), createdAt.Format(time.RFC3339), rows[1][7]) + assert.Equal(s.T(), "Needs follow up", rows[1][8]) + assert.Equal(s.T(), "Reporting period 1970-01-01 to 3000-01-01", rows[2][0]) +} + func (s *CsatSurveyHandlerTestSuite) TestUpdateReviewNotes_Success() { _, reviewer, _, _, _ := s.seedAccountCsatResponseGraph(time.Now().Add(-time.Hour), 5) var survey automation.CsatSurveyResponse