feat(csat): align report download csv

This commit is contained in:
2026-06-05 08:40:21 +08:00
parent 5c771d8c2c
commit b36cf079db
2 changed files with 127 additions and 23 deletions
+84 -23
View File
@@ -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"))
}
@@ -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