feat(reports): align v2 csv downloads
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type reportCSVMetricSet struct {
|
||||
ConversationsCount int64
|
||||
ResolvedCount int64
|
||||
AvgResolution float64
|
||||
AvgFirstResponse float64
|
||||
AvgReply float64
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) GetAgentReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "agent", businessHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var users []model.User
|
||||
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([][]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
metric := metrics[user.ID]
|
||||
rows = append(rows, append([]string{user.Name}, readableReportMetrics(metric)...))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) GetInboxReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "inbox", businessHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var inboxes []model.Inbox
|
||||
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&inboxes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([][]string, 0, len(inboxes))
|
||||
for _, inbox := range inboxes {
|
||||
metric := metrics[inbox.ID]
|
||||
rows = append(rows, []string{
|
||||
inbox.Name,
|
||||
inbox.ChannelType,
|
||||
strconv.FormatInt(metric.ConversationsCount, 10),
|
||||
formatReportDuration(metric.AvgFirstResponse),
|
||||
formatReportDuration(metric.AvgResolution),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) GetTeamReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "team", businessHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var teams []model.Team
|
||||
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&teams).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([][]string, 0, len(teams))
|
||||
for _, team := range teams {
|
||||
metric := metrics[team.ID]
|
||||
rows = append(rows, append([]string{team.Name}, readableReportMetrics(metric)...))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) GetLabelReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metrics, err := s.reportMetricsByLabel(ctx, accountID, since, until, businessHours)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var labels []model.Tag
|
||||
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&labels).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([][]string, 0, len(labels))
|
||||
for _, label := range labels {
|
||||
metric := metrics[label.ID]
|
||||
rows = append(rows, []string{
|
||||
label.Name,
|
||||
strconv.FormatInt(metric.ConversationsCount, 10),
|
||||
formatReportDuration(metric.AvgFirstResponse),
|
||||
formatReportDuration(metric.AvgResolution),
|
||||
formatReportDuration(metric.AvgReply),
|
||||
strconv.FormatInt(metric.ResolvedCount, 10),
|
||||
})
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) GetConversationsSummaryCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
|
||||
summary, err := s.conversationSummary(ctx, accountID, since, until)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return [][]string{{
|
||||
intString(summary["conversations_count"]),
|
||||
intString(summary["incoming_messages_count"]),
|
||||
intString(summary["outgoing_messages_count"]),
|
||||
formatReportDuration(floatValue(summary["avg_first_response_time"])),
|
||||
formatReportDuration(floatValue(summary["avg_resolution_time"])),
|
||||
intString(summary["resolutions_count"]),
|
||||
formatReportDuration(floatValue(summary["reply_time"])),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) GetConversationTrafficCSVRows(ctx context.Context, accountID uint, since, until time.Time, timezoneOffset float64) ([][]string, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loc := time.FixedZone("report", int(timezoneOffset*3600))
|
||||
startLocal := time.Date(since.In(loc).Year(), since.In(loc).Month(), since.In(loc).Day(), 0, 0, 0, 0, loc)
|
||||
endLocal := time.Date(until.In(loc).Year(), until.In(loc).Month(), until.In(loc).Day(), 0, 0, 0, 0, loc)
|
||||
|
||||
dates := make([]string, 0)
|
||||
for day := startLocal; day.Before(endLocal); day = day.AddDate(0, 0, 1) {
|
||||
dates = append(dates, day.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
counts := map[string]map[int]int64{}
|
||||
var conversations []model.Conversation
|
||||
if err := db.WithContext(ctx).Where("account_id = ? AND created_at >= ? AND created_at < ?", accountID, since, until).Find(&conversations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, conversation := range conversations {
|
||||
localTime := conversation.CreatedAt.In(loc)
|
||||
dateKey := localTime.Format("2006-01-02")
|
||||
if counts[dateKey] == nil {
|
||||
counts[dateKey] = map[int]int64{}
|
||||
}
|
||||
counts[dateKey][localTime.Hour()]++
|
||||
}
|
||||
|
||||
rows := make([][]string, 0, 25)
|
||||
rows = append(rows, append([]string{"Start of the hour"}, dates...))
|
||||
for hour := 0; hour < 24; hour++ {
|
||||
row := []string{fmt.Sprintf("%02d:00", hour)}
|
||||
for _, date := range dates {
|
||||
row = append(row, strconv.FormatInt(counts[date][hour], 10))
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) reportMetricsByDimension(ctx context.Context, accountID uint, since, until time.Time, dimension string, businessHours bool) (map[uint]reportCSVMetricSet, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[uint]reportCSVMetricSet{}
|
||||
field := map[string]string{"agent": "assignee_id", "inbox": "inbox_id", "team": "team_id"}[dimension]
|
||||
if field == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := loadConversationCounts(ctx, db, result, accountID, since, until, field, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadConversationCounts(ctx, db, result, accountID, since, until, field, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.loadDimensionEventAverages(ctx, db, result, accountID, since, until, dimension, businessHours); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadConversationCounts(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, field string, resolved bool) error {
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Count int64
|
||||
}
|
||||
query := db.WithContext(ctx).Model(&model.Conversation{}).
|
||||
Select(field+" AS id, COUNT(*) AS count").
|
||||
Where("account_id = ? AND "+field+" IS NOT NULL", accountID).
|
||||
Group(field)
|
||||
if resolved {
|
||||
query = query.Where("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until)
|
||||
} else {
|
||||
query = query.Where("created_at >= ? AND created_at < ?", since, until)
|
||||
}
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
metric := result[row.ID]
|
||||
if resolved {
|
||||
metric.ResolvedCount = row.Count
|
||||
} else {
|
||||
metric.ConversationsCount = row.Count
|
||||
}
|
||||
result[row.ID] = metric
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) loadDimensionEventAverages(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, dimension string, businessHours bool) error {
|
||||
valueColumn := "reporting_events.value"
|
||||
if businessHours {
|
||||
valueColumn = "reporting_events.value_in_business_hours"
|
||||
}
|
||||
field := map[string]string{"agent": "reporting_events.user_id", "inbox": "reporting_events.inbox_id", "team": "conversations.team_id"}[dimension]
|
||||
queryBase := func(names []string) *gorm.DB {
|
||||
q := db.WithContext(ctx).Model(&model.ReportingEvent{}).
|
||||
Select(field+" AS id, AVG("+valueColumn+") AS value").
|
||||
Where("reporting_events.account_id = ? AND reporting_events.name IN ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", accountID, names, since, until).
|
||||
Where(field + " IS NOT NULL").
|
||||
Group(field)
|
||||
if dimension == "team" {
|
||||
q = q.Joins("INNER JOIN conversations ON conversations.id = reporting_events.conversation_id")
|
||||
}
|
||||
return q
|
||||
}
|
||||
load := func(names []string, apply func(*reportCSVMetricSet, float64)) error {
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Value float64
|
||||
}
|
||||
if err := queryBase(names).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
metric := result[row.ID]
|
||||
apply(&metric, row.Value)
|
||||
result[row.ID] = metric
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := load([]string{model.MetricNameFirstResponse}, func(metric *reportCSVMetricSet, value float64) { metric.AvgFirstResponse = value }); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := load([]string{"conversation_resolved", model.MetricNameResolutionTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgResolution = value }); err != nil {
|
||||
return err
|
||||
}
|
||||
return load([]string{model.MetricNameReplyTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgReply = value })
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) reportMetricsByLabel(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) (map[uint]reportCSVMetricSet, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[uint]reportCSVMetricSet{}
|
||||
if err := loadLabelConversationCounts(ctx, db, result, accountID, since, until, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadLabelConversationCounts(ctx, db, result, accountID, since, until, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := loadLabelEventAverages(ctx, db, result, accountID, since, until, businessHours); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadLabelConversationCounts(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, resolved bool) error {
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Count int64
|
||||
}
|
||||
query := db.WithContext(ctx).Table("conversation_labels").
|
||||
Select("conversation_labels.tag_id AS id, COUNT(*) AS count").
|
||||
Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id").
|
||||
Where("conversation_labels.account_id = ?", accountID).
|
||||
Group("conversation_labels.tag_id")
|
||||
if resolved {
|
||||
query = query.Where("conversations.resolved_at IS NOT NULL AND conversations.resolved_at >= ? AND conversations.resolved_at < ?", since, until)
|
||||
} else {
|
||||
query = query.Where("conversations.created_at >= ? AND conversations.created_at < ?", since, until)
|
||||
}
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
metric := result[row.ID]
|
||||
if resolved {
|
||||
metric.ResolvedCount = row.Count
|
||||
} else {
|
||||
metric.ConversationsCount = row.Count
|
||||
}
|
||||
result[row.ID] = metric
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadLabelEventAverages(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, businessHours bool) error {
|
||||
valueColumn := "reporting_events.value"
|
||||
if businessHours {
|
||||
valueColumn = "reporting_events.value_in_business_hours"
|
||||
}
|
||||
load := func(names []string, apply func(*reportCSVMetricSet, float64)) error {
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Value float64
|
||||
}
|
||||
err := db.WithContext(ctx).Model(&model.ReportingEvent{}).
|
||||
Select("conversation_labels.tag_id AS id, AVG("+valueColumn+") AS value").
|
||||
Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = reporting_events.conversation_id").
|
||||
Where("reporting_events.account_id = ? AND reporting_events.name IN ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", accountID, names, since, until).
|
||||
Group("conversation_labels.tag_id").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
metric := result[row.ID]
|
||||
apply(&metric, row.Value)
|
||||
result[row.ID] = metric
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := load([]string{model.MetricNameFirstResponse}, func(metric *reportCSVMetricSet, value float64) { metric.AvgFirstResponse = value }); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := load([]string{"conversation_resolved", model.MetricNameResolutionTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgResolution = value }); err != nil {
|
||||
return err
|
||||
}
|
||||
return load([]string{model.MetricNameReplyTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgReply = value })
|
||||
}
|
||||
|
||||
func readableReportMetrics(metric reportCSVMetricSet) []string {
|
||||
return []string{
|
||||
strconv.FormatInt(metric.ConversationsCount, 10),
|
||||
formatReportDuration(metric.AvgFirstResponse),
|
||||
formatReportDuration(metric.AvgResolution),
|
||||
formatReportDuration(metric.AvgReply),
|
||||
strconv.FormatInt(metric.ResolvedCount, 10),
|
||||
}
|
||||
}
|
||||
|
||||
func formatReportDuration(seconds float64) string {
|
||||
value := int64(seconds)
|
||||
if value <= 0 {
|
||||
return "N/A"
|
||||
}
|
||||
units := []struct {
|
||||
Name string
|
||||
Seconds int64
|
||||
}{{"day", 86400}, {"hour", 3600}, {"minute", 60}, {"second", 1}}
|
||||
parts := make([]string, 0, 2)
|
||||
remaining := value
|
||||
for _, unit := range units {
|
||||
count := remaining / unit.Seconds
|
||||
remaining %= unit.Seconds
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
label := unit.Name
|
||||
if count != 1 {
|
||||
label += "s"
|
||||
}
|
||||
parts = append(parts, strconv.FormatInt(count, 10)+" "+label)
|
||||
if len(parts) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "0 seconds"
|
||||
}
|
||||
return parts[0] + optionalSecondPart(parts)
|
||||
}
|
||||
|
||||
func optionalSecondPart(parts []string) string {
|
||||
if len(parts) < 2 {
|
||||
return ""
|
||||
}
|
||||
return " " + parts[1]
|
||||
}
|
||||
|
||||
func intString(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
default:
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func floatValue(value interface{}) float64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed
|
||||
case int64:
|
||||
return float64(typed)
|
||||
case int:
|
||||
return float64(typed)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user