feat(csat): align report download csv
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user