feat(reports): add analytics timeseries rollups

This commit is contained in:
2026-06-05 21:14:45 +08:00
parent a48b9cda8e
commit 43aa2f81eb
11 changed files with 494 additions and 32 deletions
@@ -2,6 +2,7 @@ package v1
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -21,6 +22,41 @@ func NewAnalyticsHandler(svc *service.AnalyticsService) *AnalyticsHandler {
return &AnalyticsHandler{svc: svc}
}
// Index returns metric time-series for the overview charts.
// GET /api/v2/accounts/:account_id/reports?metric=...&since=...&until=...
func (h *AnalyticsHandler) Index(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
metric := c.Query("metric")
if metric == "" {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "metric parameter is required")
return
}
id := uint(0)
if rawID := c.Query("id"); rawID != "" {
parsed, err := strconv.ParseUint(rawID, 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
id = uint(parsed)
}
businessHours := c.Query("business_hours") == "true" || c.Query("business_hours") == "1"
result, err := h.svc.GetTimeseries(c.Request.Context(), accountID, metric, since, until, c.DefaultQuery("type", "account"), id, c.Query("group_by"), businessHours)
if err != nil {
applogger.L().Errorf("Timeseries report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate report")
return
}
response.OK(c, result)
}
// parseAccountID extracts account_id from URL params.
func parseAccountID(c *gin.Context) (uint, bool) {
id := getAccountID(c)
@@ -37,6 +37,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
s.db = db
s.Require().NoError(db.AutoMigrate(
&model.Account{},
&model.Conversation{},
&model.ReportingEvent{},
&model.ReportingEventsRollup{},
))
@@ -54,6 +55,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
gin.SetMode(gin.TestMode)
r := gin.New()
accounts := r.Group("/api/v1/accounts/:account_id")
accounts.GET("/reports", s.handler.Index)
accounts.GET("/reports/summary", s.handler.Summary)
accounts.GET("/reports/agents", s.handler.AgentMetrics)
accounts.GET("/reports/inboxes", s.handler.InboxMetrics)
@@ -70,6 +72,8 @@ 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 conversations")
}
// ========== parseAccountID / parseDateRange edge cases ==========
@@ -149,6 +153,23 @@ func (s *AnalyticsHandlerTestSuite) TestSummary_WithData() {
s.Equal(http.StatusOK, w.Code)
}
func (s *AnalyticsHandlerTestSuite) TestIndex_TimeseriesWithData() {
conv := model.Conversation{AccountID: s.accountID, InboxID: 1, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget"}
s.Require().NoError(s.db.Create(&conv).Error)
s.Require().NoError(s.db.Model(&conv).Updates(map[string]interface{}{"created_at": parseTime("2025-01-15T10:00:00Z")}).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet,
"/api/v1/accounts/1/reports?metric=conversations_count&since=2025-01-01T00:00:00Z&until=2025-02-01T00:00:00Z&type=account&group_by=day", nil)
s.router.ServeHTTP(w, req)
s.Equal(http.StatusOK, w.Code)
var body map[string]interface{}
s.NoError(json.Unmarshal(w.Body.Bytes(), &body))
data := body["data"].([]interface{})
s.Len(data, 1)
s.Equal(float64(1), data[0].(map[string]interface{})["value"])
}
// ========== AgentMetrics ==========
func (s *AnalyticsHandlerTestSuite) TestAgentMetrics_InvalidAccountID() {
@@ -251,4 +272,4 @@ func (s *AnalyticsHandlerTestSuite) TestNilService() {
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}
}