feat(reports): align v2 csv downloads

This commit is contained in:
2026-06-06 23:25:58 +08:00
parent 2d94ec9b53
commit ab130d5b2e
4 changed files with 719 additions and 19 deletions
+136 -13
View File
@@ -1,6 +1,7 @@
package v1
import (
"encoding/csv"
"net/http"
"strconv"
"time"
@@ -100,6 +101,93 @@ func parseChatwootReportTime(value string) (time.Time, error) {
return time.Parse(time.RFC3339, value)
}
func parseReportBusinessHours(c *gin.Context) bool {
return c.Query("business_hours") == "true" || c.Query("business_hours") == "1"
}
func parseConversationTrafficRange(c *gin.Context) (time.Time, time.Time, float64, bool) {
timezoneOffset := 0.0
if raw := c.Query("timezone_offset"); raw != "" {
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid timezone_offset")
return time.Time{}, time.Time{}, 0, false
}
timezoneOffset = parsed
}
if c.Query("since") != "" || c.Query("until") != "" {
since, until, ok := parseDateRange(c)
return since, until, timezoneOffset, ok
}
daysBefore := 6
if raw := c.Query("days_before"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid days_before")
return time.Time{}, time.Time{}, 0, false
}
daysBefore = parsed
}
loc := fixedOffsetLocation(timezoneOffset)
now := time.Now().In(loc)
untilLocal := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
sinceLocal := untilLocal.AddDate(0, 0, -daysBefore)
return sinceLocal.UTC(), untilLocal.UTC(), timezoneOffset, true
}
func fixedOffsetLocation(offsetHours float64) *time.Location {
seconds := int(offsetHours * 3600)
return time.FixedZone(reportTimezoneName(offsetHours), seconds)
}
func reportTimezoneName(offsetHours float64) string {
if offsetHours == 0 {
return "UTC"
}
sign := "+"
if offsetHours < 0 {
sign = "-"
offsetHours = -offsetHours
}
totalMinutes := int(offsetHours*60 + 0.5)
return "UTC" + sign + twoDigit(totalMinutes/60) + ":" + twoDigit(totalMinutes%60)
}
func twoDigit(value int) string {
if value < 10 {
return "0" + strconv.Itoa(value)
}
return strconv.Itoa(value)
}
func writeReportCSV(c *gin.Context, filename string, since, until time.Time, headers []string, rows [][]string) {
c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment; filename="+filename+".csv")
writer := csv.NewWriter(c.Writer)
_ = writer.Write([]string{"Reporting period " + since.Format("2006-01-02") + " to " + until.Format("2006-01-02")})
_ = writer.Write([]string{})
_ = writer.Write(headers)
for _, row := range rows {
_ = writer.Write(row)
}
writer.Flush()
}
func writeConversationTrafficCSV(c *gin.Context, timezoneOffset float64, rows [][]string) {
c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment; filename=conversation_traffic_reports.csv")
writer := csv.NewWriter(c.Writer)
_ = writer.Write([]string{"Timezone", reportTimezoneName(timezoneOffset)})
_ = writer.Write([]string{})
for _, row := range rows {
_ = writer.Write(row)
}
writer.Flush()
}
// Summary returns account-level aggregated metrics.
// GET /api/v1/accounts/:account_id/reports/summary
func (h *AnalyticsHandler) Summary(c *gin.Context) {
@@ -134,14 +222,21 @@ func (h *AnalyticsHandler) AgentMetrics(c *gin.Context) {
return
}
result, err := h.svc.GetAgentMetrics(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetAgentReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Agent metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate agent metrics")
return
}
c.JSON(http.StatusOK, result)
writeReportCSV(c, "agents_report", since, until, []string{
"Agent name",
"Assigned conversations",
"Avg first response time",
"Avg resolution time",
"Avg customer waiting time",
"Resolution Count",
}, result)
}
// InboxMetrics returns metrics grouped by inbox.
@@ -156,14 +251,20 @@ func (h *AnalyticsHandler) InboxMetrics(c *gin.Context) {
return
}
result, err := h.svc.GetInboxMetrics(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetInboxReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Inbox metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate inbox metrics")
return
}
c.JSON(http.StatusOK, result)
writeReportCSV(c, "inboxes_report", since, until, []string{
"Inbox name",
"Inbox type",
"No. of conversations",
"Avg first response time",
"Avg resolution time",
}, result)
}
// LabelMetrics returns metrics grouped by label.
@@ -178,14 +279,21 @@ func (h *AnalyticsHandler) LabelMetrics(c *gin.Context) {
return
}
result, err := h.svc.GetLabelMetrics(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetLabelReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Label metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate label metrics")
return
}
c.JSON(http.StatusOK, result)
writeReportCSV(c, "labels_report", since, until, []string{
"Label",
"No. of conversations",
"Avg first response time",
"Avg resolution time",
"Avg reply time",
"Resolution Count",
}, result)
}
// TeamMetrics returns metrics grouped by team.
@@ -200,14 +308,21 @@ func (h *AnalyticsHandler) TeamMetrics(c *gin.Context) {
return
}
result, err := h.svc.GetTeamMetrics(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetTeamReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Team metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate team metrics")
return
}
c.JSON(http.StatusOK, result)
writeReportCSV(c, "teams_report", since, until, []string{
"Team name",
"Conversations count",
"Avg first response time",
"Avg resolution time",
"Avg customer waiting time",
"Resolution Count",
}, result)
}
// ConversationTraffic returns daily conversation traffic time-series.
@@ -217,19 +332,19 @@ func (h *AnalyticsHandler) ConversationTraffic(c *gin.Context) {
if !ok {
return
}
since, until, ok := parseDateRange(c)
since, until, timezoneOffset, ok := parseConversationTrafficRange(c)
if !ok {
return
}
result, err := h.svc.GetConversationTraffic(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetConversationTrafficCSVRows(c.Request.Context(), accountID, since, until, timezoneOffset)
if err != nil {
applogger.L().Errorf("Conversation traffic report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversation traffic")
return
}
c.JSON(http.StatusOK, result)
writeConversationTrafficCSV(c, timezoneOffset, result)
}
// BotSummary returns bot-level summary metrics.
@@ -294,14 +409,22 @@ func (h *AnalyticsHandler) ConversationsSummary(c *gin.Context) {
return
}
result, err := h.svc.GetConversationsSummary(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetConversationsSummaryCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Conversations summary report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversations summary")
return
}
c.JSON(http.StatusOK, result)
writeReportCSV(c, "conversations_summary_report", since, until, []string{
"Conversations",
"Messages received",
"Messages sent",
"Avg first response time",
"Avg resolution time",
"Resolution count",
"Avg customer waiting time",
}, result)
}
// BotMetrics returns bot metrics.
@@ -1,9 +1,11 @@
package v1
import (
"encoding/csv"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -37,7 +39,13 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
s.db = db
s.Require().NoError(db.AutoMigrate(
&model.Account{},
&model.User{},
&model.Inbox{},
&model.Team{},
&model.Tag{},
&model.Conversation{},
&model.ConversationLabel{},
&model.Message{},
&model.ReportingEvent{},
&model.ReportingEventsRollup{},
))
@@ -62,6 +70,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
accounts.GET("/reports/labels", s.handler.LabelMetrics)
accounts.GET("/reports/teams", s.handler.TeamMetrics)
accounts.GET("/reports/conversation_traffic", s.handler.ConversationTraffic)
accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary)
s.router = r
}
@@ -73,7 +82,13 @@ func (s *AnalyticsHandlerTestSuite) TearDownSuite() {
func (s *AnalyticsHandlerTestSuite) SetupTest() {
s.db.Exec("DELETE FROM reporting_events")
s.db.Exec("DELETE FROM reporting_events_rollups")
s.db.Exec("DELETE FROM messages")
s.db.Exec("DELETE FROM conversation_labels")
s.db.Exec("DELETE FROM conversations")
s.db.Exec("DELETE FROM tags")
s.db.Exec("DELETE FROM teams")
s.db.Exec("DELETE FROM inboxes")
s.db.Exec("DELETE FROM users")
}
// ========== parseAccountID / parseDateRange edge cases ==========
@@ -186,6 +201,31 @@ func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_EmptyData() {
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_ReturnsChatwootCSVDownload() {
user := model.User{AccountID: s.accountID, Name: "Ada Agent", Email: "ada@example.com", Password: "secret"}
s.Require().NoError(s.db.Create(&user).Error)
resolvedAt := parseTime("2025-01-16T10:00:00Z")
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, AssigneeID: &user.ID, Status: string(model.ConversationStatusResolved), ChannelType: "web_widget", Channel: "web_widget", ResolvedAt: &resolvedAt, Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}}
s.Require().NoError(s.db.Create(&conv).Error)
s.seedReportingEvent(model.MetricNameFirstResponse, 120, user.ID, nil, conv.ID, "2025-01-15T10:05:00Z")
s.seedReportingEvent(model.MetricNameReplyTime, 45, user.ID, nil, conv.ID, "2025-01-15T10:06:00Z")
s.seedReportingEvent(model.MetricNameResolutionTime, 3600, user.ID, nil, conv.ID, "2025-01-16T10:00:00Z")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/agents?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("text/csv", w.Header().Get("Content-Type"))
s.Equal("attachment; filename=agents_report.csv", w.Header().Get("Content-Disposition"))
s.NotContains(w.Body.String(), "success")
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Reporting period 2025-01-01 to 2025-02-01"}, rows[0])
s.Equal("Agent name", rows[1][0])
s.Equal([]string{"Ada Agent", "1", "2 minutes", "1 hour", "45 seconds", "1"}, rows[2])
}
// ========== InboxMetrics ==========
func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_InvalidAccountID() {
@@ -204,6 +244,21 @@ func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_EmptyData() {
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestInboxMetrics_ReturnsCSVHeaders() {
inbox := model.Inbox{AccountID: s.accountID, Name: "Support", ChannelType: "Channel::WebWidget", ChannelID: 1}
s.Require().NoError(s.db.Create(&inbox).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/inboxes?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=inboxes_report.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Inbox name", "Inbox type", "No. of conversations", "Avg first response time", "Avg resolution time"}, rows[1])
s.Equal([]string{"Support", "Channel::WebWidget", "0", "N/A", "N/A"}, rows[2])
}
// ========== LabelMetrics ==========
func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_InvalidAccountID() {
@@ -222,6 +277,21 @@ func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_EmptyData() {
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestLabelMetrics_ReturnsCSVHeaders() {
label := model.Tag{AccountID: s.accountID, Name: "billing"}
s.Require().NoError(s.db.Create(&label).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/labels?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=labels_report.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Label", "No. of conversations", "Avg first response time", "Avg resolution time", "Avg reply time", "Resolution Count"}, rows[1])
s.Equal([]string{"billing", "0", "N/A", "N/A", "N/A", "0"}, rows[2])
}
// ========== TeamMetrics ==========
func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_InvalidAccountID() {
@@ -240,6 +310,21 @@ func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_EmptyData() {
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestTeamMetrics_ReturnsCSVHeaders() {
team := model.Team{AccountID: s.accountID, Name: "Escalations"}
s.Require().NoError(s.db.Create(&team).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/teams?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=teams_report.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Team name", "Conversations count", "Avg first response time", "Avg resolution time", "Avg customer waiting time", "Resolution Count"}, rows[1])
s.Equal([]string{"Escalations", "0", "N/A", "N/A", "N/A", "0"}, rows[2])
}
// ========== ConversationTraffic ==========
func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_InvalidAccountID() {
@@ -253,11 +338,51 @@ func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_InvalidAccountID() {
func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_EmptyData() {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/conversation_traffic?since=2025-01-01T00:00:00Z&until=2025-01-31T00:00:00Z", nil)
"/api/v1/accounts/1/reports/conversation_traffic?days_before=1&timezone_offset=0", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestConversationTraffic_ReturnsCSVWithoutSinceUntil() {
now := time.Now().UTC()
yesterdayNoon := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC).AddDate(0, 0, -1)
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: yesterdayNoon}}
s.Require().NoError(s.db.Create(&conv).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/conversation_traffic?days_before=1&timezone_offset=0", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=conversation_traffic_reports.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Timezone", "UTC"}, rows[0])
s.Equal("Start of the hour", rows[1][0])
s.Equal("12:00", rows[14][0])
s.Equal("1", rows[14][1])
}
func (s *AnalyticsHandlerTestSuite) TestConversationsSummary_ReturnsCSVDownload() {
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:00:00Z")}}
s.Require().NoError(s.db.Create(&conv).Error)
incoming := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "hi", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:01:00Z")}}
outgoing := model.Message{AccountID: s.accountID, InboxID: 1, ConversationID: conv.ID, MessageType: string(model.MessageTypeOutgoing), Content: "hello", Base: model.Base{CreatedAt: parseTime("2025-01-15T10:02:00Z")}}
s.Require().NoError(s.db.Create(&incoming).Error)
s.Require().NoError(s.db.Create(&outgoing).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports/conversations_summary?since=1735689600&until=1738368000", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
s.Equal("attachment; filename=conversations_summary_report.csv", w.Header().Get("Content-Disposition"))
rows := readCSVRows(s.T(), w.Body.String())
s.Equal([]string{"Conversations", "Messages received", "Messages sent", "Avg first response time", "Avg resolution time", "Resolution count", "Avg customer waiting time"}, rows[1])
s.Equal("1", rows[2][0])
s.Equal("1", rows[2][1])
s.Equal("1", rows[2][2])
}
// ========== Nil service guard ==========
func (s *AnalyticsHandlerTestSuite) TestNilService() {
@@ -271,3 +396,30 @@ func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}
func (s *AnalyticsHandlerTestSuite) seedReportingEvent(name string, value float64, userID uint, inboxID *uint, conversationID uint, createdAt string) {
event := model.ReportingEvent{
AccountID: s.accountID,
Name: name,
Value: value,
ValueInBusinessHours: value,
UserID: &userID,
InboxID: inboxID,
ConversationID: &conversationID,
EventStartTime: parseTime(createdAt),
EventEndTime: parseTime(createdAt),
Base: model.Base{CreatedAt: parseTime(createdAt)},
}
s.Require().NoError(s.db.Create(&event).Error)
}
func readCSVRows(t *testing.T, body string) [][]string {
t.Helper()
reader := csv.NewReader(strings.NewReader(body))
reader.FieldsPerRecord = -1
rows, err := reader.ReadAll()
if err != nil {
t.Fatalf("failed to read csv: %v\n%s", err, body)
}
return rows
}