feat(reports): align inbox label matrix filters
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/csv"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -498,8 +499,12 @@ func (h *AnalyticsHandler) InboxLabelMatrix(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter, ok := parseInboxLabelMatrixFilter(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.svc.GetInboxLabelMatrix(c.Request.Context(), accountID)
|
||||
result, err := h.svc.GetInboxLabelMatrix(c.Request.Context(), accountID, filter)
|
||||
if err != nil {
|
||||
applogger.L().Errorf("Inbox label matrix report: %v", err)
|
||||
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate inbox label matrix")
|
||||
@@ -509,6 +514,50 @@ func (h *AnalyticsHandler) InboxLabelMatrix(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func parseInboxLabelMatrixFilter(c *gin.Context) (service.InboxLabelMatrixFilter, bool) {
|
||||
filter := service.InboxLabelMatrixFilter{
|
||||
InboxIDs: parseReportUintList(c, "inbox_ids"),
|
||||
LabelIDs: parseReportUintList(c, "label_ids"),
|
||||
}
|
||||
sinceRaw := c.Query("since")
|
||||
untilRaw := c.Query("until")
|
||||
if sinceRaw == "" || untilRaw == "" {
|
||||
return filter, true
|
||||
}
|
||||
since, err := parseChatwootReportTime(sinceRaw)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid since date format")
|
||||
return service.InboxLabelMatrixFilter{}, false
|
||||
}
|
||||
until, err := parseChatwootReportTime(untilRaw)
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid until date format")
|
||||
return service.InboxLabelMatrixFilter{}, false
|
||||
}
|
||||
filter.Since = since
|
||||
filter.Until = until
|
||||
return filter, true
|
||||
}
|
||||
|
||||
func parseReportUintList(c *gin.Context, key string) []uint {
|
||||
values := append([]string{}, c.QueryArray(key)...)
|
||||
values = append(values, c.QueryArray(key+"[]")...)
|
||||
ids := make([]uint, 0, len(values))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := strconv.ParseUint(part, 10, 64)
|
||||
if err == nil && parsed > 0 {
|
||||
ids = append(ids, uint(parsed))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// FirstResponseTimeDistribution returns first response time distribution.
|
||||
// GET /api/v1/accounts/:account_id/reports/first_response_time_distribution
|
||||
// Reference: Chatwoot reports#first_response_time_distribution
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -73,6 +74,7 @@ func (s *AnalyticsHandlerTestSuite) SetupSuite() {
|
||||
accounts.GET("/reports/conversation_traffic", s.handler.ConversationTraffic)
|
||||
accounts.GET("/reports/conversations", s.handler.Conversations)
|
||||
accounts.GET("/reports/conversations_summary", s.handler.ConversationsSummary)
|
||||
accounts.GET("/reports/inbox_label_matrix", s.handler.InboxLabelMatrix)
|
||||
s.router = r
|
||||
}
|
||||
|
||||
@@ -469,6 +471,39 @@ func (s *AnalyticsHandlerTestSuite) TestConversations_AgentTypeReturnsChatwootAg
|
||||
s.Equal("https://example.com/low.png", payload[1]["thumbnail"])
|
||||
}
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestInboxLabelMatrix_ParsesFrontendFilters() {
|
||||
since := parseTime("2025-01-01T00:00:00Z")
|
||||
inbox := model.Inbox{AccountID: s.accountID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
|
||||
otherInbox := model.Inbox{AccountID: s.accountID, Name: "Other", ChannelType: "web_widget", ChannelID: 2, Enabled: true}
|
||||
s.Require().NoError(s.db.Create(&inbox).Error)
|
||||
s.Require().NoError(s.db.Create(&otherInbox).Error)
|
||||
label := model.Tag{AccountID: s.accountID, Name: "vip"}
|
||||
otherLabel := model.Tag{AccountID: s.accountID, Name: "bug"}
|
||||
s.Require().NoError(s.db.Create(&label).Error)
|
||||
s.Require().NoError(s.db.Create(&otherLabel).Error)
|
||||
conv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(time.Hour)}}
|
||||
oldConv := model.Conversation{AccountID: s.accountID, InboxID: inbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(-time.Hour)}}
|
||||
otherConv := model.Conversation{AccountID: s.accountID, InboxID: otherInbox.ID, ContactID: 1, Status: string(model.ConversationStatusOpen), ChannelType: "web_widget", Channel: "web_widget", Base: model.Base{CreatedAt: since.Add(time.Hour)}}
|
||||
s.Require().NoError(s.db.Create(&conv).Error)
|
||||
s.Require().NoError(s.db.Create(&oldConv).Error)
|
||||
s.Require().NoError(s.db.Create(&otherConv).Error)
|
||||
s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conv.ID, TagID: label.ID}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: conv.ID, TagID: otherLabel.ID}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: oldConv.ID, TagID: label.ID}).Error)
|
||||
s.Require().NoError(s.db.Create(&model.ConversationLabel{AccountID: s.accountID, ConversationID: otherConv.ID, TagID: label.ID}).Error)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/reports/inbox_label_matrix?since=1735689600&until=1735776000&inbox_ids[]="+strconv.FormatUint(uint64(inbox.ID), 10)+"&label_ids="+strconv.FormatUint(uint64(label.ID), 10), nil)
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var payload map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
s.Equal([]interface{}{[]interface{}{float64(1)}}, payload["matrix"])
|
||||
s.Require().Len(payload["inboxes"], 1)
|
||||
s.Require().Len(payload["labels"], 1)
|
||||
}
|
||||
|
||||
// ========== Nil service guard ==========
|
||||
|
||||
func (s *AnalyticsHandlerTestSuite) TestNilService() {
|
||||
|
||||
@@ -169,12 +169,49 @@ func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing.
|
||||
assert.Equal(t, inbox.ID, outRows[0]["id"])
|
||||
assert.Equal(t, int64(1), outRows[0]["outgoing_messages_count"])
|
||||
|
||||
matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID)
|
||||
matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID, InboxLabelMatrixFilter{})
|
||||
require.NoError(t, err)
|
||||
matrixMap := matrix.(map[string]interface{})
|
||||
assert.Equal(t, [][]int64{{1}}, matrixMap["matrix"])
|
||||
}
|
||||
|
||||
func TestAnalyticsInboxLabelMatrixHonorsFiltersAndRange(t *testing.T) {
|
||||
db, svc, account, inbox, contact, _, _ := setupAnalyticsP513Test(t)
|
||||
since := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC)
|
||||
until := since.Add(24 * time.Hour)
|
||||
otherInbox := &model.Inbox{AccountID: account.ID, Name: "Other", ChannelType: "web_widget", ChannelID: 2, Enabled: true}
|
||||
require.NoError(t, db.Create(otherInbox).Error)
|
||||
keepLabel := &model.Tag{AccountID: account.ID, Name: "keep"}
|
||||
skipLabel := &model.Tag{AccountID: account.ID, Name: "skip"}
|
||||
require.NoError(t, db.Create(keepLabel).Error)
|
||||
require.NoError(t, db.Create(skipLabel).Error)
|
||||
inRange := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
outOfRange := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
otherInboxConversation := &model.Conversation{AccountID: account.ID, InboxID: otherInbox.ID, ContactID: contact.ID, ChannelType: otherInbox.ChannelType, Channel: otherInbox.ChannelType}
|
||||
require.NoError(t, db.Create(inRange).Error)
|
||||
require.NoError(t, db.Create(outOfRange).Error)
|
||||
require.NoError(t, db.Create(otherInboxConversation).Error)
|
||||
require.NoError(t, db.Model(inRange).Update("created_at", since.Add(time.Hour)).Error)
|
||||
require.NoError(t, db.Model(outOfRange).Update("created_at", since.Add(-time.Hour)).Error)
|
||||
require.NoError(t, db.Model(otherInboxConversation).Update("created_at", since.Add(2*time.Hour)).Error)
|
||||
require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: inRange.ID, TagID: keepLabel.ID}).Error)
|
||||
require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: inRange.ID, TagID: skipLabel.ID}).Error)
|
||||
require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: outOfRange.ID, TagID: keepLabel.ID}).Error)
|
||||
require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: otherInboxConversation.ID, TagID: keepLabel.ID}).Error)
|
||||
|
||||
matrix, err := svc.GetInboxLabelMatrix(context.Background(), account.ID, InboxLabelMatrixFilter{
|
||||
Since: since,
|
||||
Until: until,
|
||||
InboxIDs: []uint{inbox.ID},
|
||||
LabelIDs: []uint{keepLabel.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
payload := matrix.(map[string]interface{})
|
||||
assert.Equal(t, [][]int64{{1}}, payload["matrix"])
|
||||
require.Len(t, payload["inboxes"], 1)
|
||||
require.Len(t, payload["labels"], 1)
|
||||
}
|
||||
|
||||
func TestAnalyticsTimeseriesAndRollupWorker(t *testing.T) {
|
||||
db, svc, account, inbox, contact, user, _ := setupAnalyticsP513Test(t)
|
||||
since := time.Date(2026, 6, 2, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -773,17 +773,25 @@ func (s *AnalyticsService) botMetricDistinctCounts(ctx context.Context, db *gorm
|
||||
return resolutions, int64(len(handoffIDs)), nil
|
||||
}
|
||||
|
||||
func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint) (map[string]interface{}, error) {
|
||||
func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint, filter InboxLabelMatrixFilter) (map[string]interface{}, error) {
|
||||
db, err := s.analyticsDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var inboxes []model.Inbox
|
||||
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&inboxes).Error; err != nil {
|
||||
inboxQ := db.WithContext(ctx).Where("account_id = ?", accountID)
|
||||
if len(filter.InboxIDs) > 0 {
|
||||
inboxQ = inboxQ.Where("id IN ?", filter.InboxIDs)
|
||||
}
|
||||
if err := inboxQ.Order("name ASC").Find(&inboxes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tags []model.Tag
|
||||
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&tags).Error; err != nil {
|
||||
tagQ := db.WithContext(ctx).Where("account_id = ?", accountID)
|
||||
if len(filter.LabelIDs) > 0 {
|
||||
tagQ = tagQ.Where("id IN ?", filter.LabelIDs)
|
||||
}
|
||||
if err := tagQ.Order("name ASC").Find(&tags).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type countRow struct {
|
||||
@@ -793,12 +801,23 @@ func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint)
|
||||
}
|
||||
var rows []countRow
|
||||
if len(inboxes) > 0 && len(tags) > 0 {
|
||||
if err := db.WithContext(ctx).Table("conversation_labels").
|
||||
inboxIDs := make([]uint, 0, len(inboxes))
|
||||
for _, inbox := range inboxes {
|
||||
inboxIDs = append(inboxIDs, inbox.ID)
|
||||
}
|
||||
labelIDs := make([]uint, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
labelIDs = append(labelIDs, tag.ID)
|
||||
}
|
||||
countsQ := db.WithContext(ctx).Table("conversation_labels").
|
||||
Select("conversations.inbox_id AS inbox_id, conversation_labels.tag_id AS tag_id, COUNT(*) AS count").
|
||||
Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id").
|
||||
Where("conversation_labels.account_id = ?", accountID).
|
||||
Group("conversations.inbox_id, conversation_labels.tag_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
Where("conversation_labels.account_id = ? AND conversations.account_id = ?", accountID, accountID).
|
||||
Where("conversations.inbox_id IN ? AND conversation_labels.tag_id IN ?", inboxIDs, labelIDs)
|
||||
if !filter.Since.IsZero() && !filter.Until.IsZero() {
|
||||
countsQ = countsQ.Where("conversations.created_at >= ? AND conversations.created_at < ?", filter.Since, filter.Until)
|
||||
}
|
||||
if err := countsQ.Group("conversations.inbox_id, conversation_labels.tag_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,14 @@ type ReportAgentConversationMetric struct {
|
||||
Metric map[string]int64 `json:"metric"`
|
||||
}
|
||||
|
||||
// InboxLabelMatrixFilter mirrors V2::Reports::InboxLabelMatrixBuilder params.
|
||||
type InboxLabelMatrixFilter struct {
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
InboxIDs []uint
|
||||
LabelIDs []uint
|
||||
}
|
||||
|
||||
// GetSummary returns account-level aggregated metrics for a date range.
|
||||
func (s *AnalyticsService) GetSummary(ctx context.Context, accountID uint, since, until time.Time) (*SummaryResponse, error) {
|
||||
if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil {
|
||||
@@ -452,8 +460,8 @@ func (s *AnalyticsService) GetBotMetrics(ctx context.Context, accountID uint, si
|
||||
|
||||
// GetInboxLabelMatrix returns inbox-label matrix data.
|
||||
// Reference: Chatwoot reports#inbox_label_matrix — V2::Reports::InboxLabelMatrixBuilder
|
||||
func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint) (interface{}, error) {
|
||||
return s.inboxLabelMatrix(ctx, accountID)
|
||||
func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint, filter InboxLabelMatrixFilter) (interface{}, error) {
|
||||
return s.inboxLabelMatrix(ctx, accountID, filter)
|
||||
}
|
||||
|
||||
// GetFirstResponseTimeDistribution returns first response time distribution.
|
||||
|
||||
Reference in New Issue
Block a user