feat(reports): derive analytics aggregates

This commit is contained in:
2026-06-05 20:53:30 +08:00
parent 37bdac422a
commit a48b9cda8e
8 changed files with 726 additions and 72 deletions
+8 -7
View File
@@ -187,6 +187,7 @@ func (h *AnalyticsHandler) ConversationTraffic(c *gin.Context) {
response.OK(c, result)
}
// BotSummary returns bot-level summary metrics.
// GET /api/v1/accounts/:account_id/reports/bot_summary
// Reference: Chatwoot reports#bot_summary
@@ -226,12 +227,7 @@ func (h *AnalyticsHandler) Conversations(c *gin.Context) {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, since, until)
result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, time.Time{}, time.Time{})
if err != nil {
applogger.L().Errorf("Conversations report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversations report")
@@ -341,8 +337,13 @@ func (h *AnalyticsHandler) OutgoingMessagesCount(c *gin.Context) {
if !ok {
return
}
groupBy := c.Query("group_by")
if groupBy != "agent" && groupBy != "team" && groupBy != "inbox" && groupBy != "label" {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid group_by")
return
}
result, err := h.svc.GetOutgoingMessagesCount(c.Request.Context(), accountID, since, until)
result, err := h.svc.GetOutgoingMessagesCountGrouped(c.Request.Context(), accountID, since, until, groupBy)
if err != nil {
applogger.L().Errorf("Outgoing messages count report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate outgoing messages count")
+12 -7
View File
@@ -2,6 +2,7 @@ package v1
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -30,19 +31,23 @@ func (h *LiveReportHandler) ConversationMetrics(c *gin.Context) {
return
}
// Chatwoot supports team_id filter
teamIDStr := c.Query("team_id")
teamID := uint(0)
if teamIDStr := c.Query("team_id"); teamIDStr != "" {
parsed, err := strconv.ParseUint(teamIDStr, 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid team_id")
return
}
teamID = uint(parsed)
}
result, err := h.svc.GetConversationMetrics(c.Request.Context(), accountID)
result, err := h.svc.GetConversationMetricsForTeam(c.Request.Context(), accountID, teamID)
if err != nil {
applogger.L().Errorf("Live conversation metrics: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to get conversation metrics")
return
}
// Chatwoot also filters by team_id if provided — TODO: implement team filter
_ = teamIDStr
response.OK(c, result)
}
@@ -72,4 +77,4 @@ func (h *LiveReportHandler) GroupedConversationMetrics(c *gin.Context) {
}
response.OK(c, result)
}
}
+8 -1
View File
@@ -17,6 +17,13 @@ func NewReportingEventRepo(db *gorm.DB) *ReportingEventRepo {
return &ReportingEventRepo{db: db}
}
func (r *ReportingEventRepo) DB() *gorm.DB {
if r == nil {
return nil
}
return r.db
}
func (r *ReportingEventRepo) Create(ctx context.Context, event *model.ReportingEvent) error {
return r.db.WithContext(ctx).Create(event).Error
}
@@ -75,4 +82,4 @@ func (r *ReportingEventRepo) FindByAccountIDAndTimeRange(ctx context.Context, ac
Where("account_id = ? AND event_start_time >= ? AND event_end_time <= ?", accountID, since, until).
Find(&events).Error
return events, err
}
}
@@ -17,6 +17,13 @@ func NewReportingEventsRollupRepo(db *gorm.DB) *ReportingEventsRollupRepo {
return &ReportingEventsRollupRepo{db: db}
}
func (r *ReportingEventsRollupRepo) DB() *gorm.DB {
if r == nil {
return nil
}
return r.db
}
func (r *ReportingEventsRollupRepo) Create(ctx context.Context, rollup *model.ReportingEventsRollup) error {
return r.db.WithContext(ctx).Create(rollup).Error
}
@@ -82,4 +89,4 @@ func (r *ReportingEventsRollupRepo) FindByAccountAndDateRangeWithDimension(ctx c
}
err := query.Find(&rollups).Error
return rollups, err
}
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"context"
"testing"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupAnalyticsP513Test(t *testing.T) (*gorm.DB, *AnalyticsService, *model.Account, *model.Inbox, *model.Contact, *model.User, *model.Team) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.Account{},
&model.User{},
&model.AccountUser{},
&model.Team{},
&model.Inbox{},
&model.Contact{},
&model.Conversation{},
&model.Message{},
&model.ReportingEvent{},
&model.ReportingEventsRollup{},
&model.AgentBot{},
&model.AgentBotInbox{},
&model.Tag{},
&model.ConversationLabel{},
))
t.Cleanup(func() {
sqlDB, _ := db.DB()
sqlDB.Close()
})
account := &model.Account{Name: "Analytics"}
require.NoError(t, db.Create(account).Error)
user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent@example.com", Password: "secret", Active: true}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: string(model.AccountUserRoleAgent)}).Error)
team := &model.Team{AccountID: account.ID, Name: "Support"}
require.NoError(t, db.Create(team).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1, Enabled: true}
require.NoError(t, db.Create(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Customer", Email: "customer@example.com"}
require.NoError(t, db.Create(contact).Error)
svc := NewAnalyticsService(repository.NewReportingEventRepo(db), repository.NewReportingEventsRollupRepo(db))
return db, svc, account, inbox, contact, user, team
}
func TestAnalyticsLiveConversationMetricsAreDerivedFromConversations(t *testing.T) {
db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t)
now := int64(1760000000)
firstReply := now - 60
assigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
unassigned := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, TeamID: &team.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType, FirstReplyCreatedAt: &firstReply}
pending := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusPending), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
otherAccount := &model.Account{Name: "Other"}
require.NoError(t, db.Create(otherAccount).Error)
other := &model.Conversation{AccountID: otherAccount.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
require.NoError(t, db.Create(assigned).Error)
require.NoError(t, db.Create(unassigned).Error)
require.NoError(t, db.Create(pending).Error)
require.NoError(t, db.Create(other).Error)
metrics, err := svc.GetConversationMetrics(context.Background(), account.ID)
require.NoError(t, err)
assert.Equal(t, int64(2), metrics.OpenCount)
assert.Equal(t, int64(1), metrics.UnattendedCount)
assert.Equal(t, int64(1), metrics.UnassignedCount)
assert.Equal(t, int64(1), metrics.PendingCount)
teamMetrics, err := svc.GetConversationMetricsForTeam(context.Background(), account.ID, team.ID)
require.NoError(t, err)
assert.Equal(t, int64(2), teamMetrics.OpenCount)
assert.Equal(t, int64(0), teamMetrics.PendingCount)
grouped, err := svc.GetGroupedConversationMetrics(context.Background(), account.ID, "assignee_id")
require.NoError(t, err)
require.Len(t, grouped, 2)
assert.Equal(t, int64(1), grouped[0]["open"])
assert.Nil(t, grouped[0]["assignee_id"])
assert.Equal(t, user.ID, grouped[1]["assignee_id"])
assert.Equal(t, int64(1), grouped[1]["open"])
}
func TestAnalyticsReportsUsePersistedConversationMessageAndEventRows(t *testing.T) {
db, svc, account, inbox, contact, user, team := setupAnalyticsP513Test(t)
since := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
until := since.Add(24 * time.Hour)
resolvedAt := since.Add(3 * time.Hour)
conv := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, AssigneeID: &user.ID, TeamID: &team.ID, Status: string(model.ConversationStatusResolved), ResolvedAt: &resolvedAt, ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
require.NoError(t, db.Create(conv).Error)
require.NoError(t, db.Model(conv).Updates(map[string]interface{}{"created_at": since.Add(time.Hour)}).Error)
senderID := user.ID
require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(2 * time.Hour)}, AccountID: account.ID, ConversationID: conv.ID, InboxID: inbox.ID, SenderID: &senderID, SenderType: "User", MessageType: string(model.MessageTypeOutgoing), Content: "hello"}).Error)
require.NoError(t, db.Create(&model.Message{Base: model.Base{CreatedAt: since.Add(90 * time.Minute)}, AccountID: account.ID, ConversationID: conv.ID, InboxID: inbox.ID, MessageType: string(model.MessageTypeIncoming), Content: "hi"}).Error)
bot := &model.AgentBot{AccountID: &account.ID, Name: "Bot"}
require.NoError(t, db.Create(bot).Error)
require.NoError(t, db.Create(&model.AgentBotInbox{AccountID: &account.ID, AgentBotID: bot.ID, InboxID: inbox.ID, Status: model.AgentBotInboxActive}).Error)
tag := &model.Tag{AccountID: account.ID, Name: "vip"}
require.NoError(t, db.Create(tag).Error)
require.NoError(t, db.Create(&model.ConversationLabel{AccountID: account.ID, ConversationID: conv.ID, TagID: tag.ID}).Error)
require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(30 * time.Minute)}, AccountID: account.ID, Name: model.MetricNameFirstResponse, Value: 1800, ConversationID: &conv.ID, InboxID: &inbox.ID, UserID: &user.ID, EventStartTime: since, EventEndTime: since.Add(30 * time.Minute)}).Error)
require.NoError(t, db.Create(&model.ReportingEvent{Base: model.Base{CreatedAt: since.Add(time.Hour)}, AccountID: account.ID, Name: "conversation_bot_resolved", Value: 1, ConversationID: &conv.ID, InboxID: &inbox.ID, EventStartTime: since, EventEndTime: since.Add(time.Hour)}).Error)
summary, err := svc.GetConversationsSummary(context.Background(), account.ID, since, until)
require.NoError(t, err)
summaryMap := summary.(map[string]interface{})
assert.Equal(t, int64(1), summaryMap["conversations_count"])
assert.Equal(t, int64(1), summaryMap["incoming_messages_count"])
assert.Equal(t, int64(1), summaryMap["outgoing_messages_count"])
assert.Equal(t, int64(1), summaryMap["resolutions_count"])
assert.Equal(t, 1800.0, summaryMap["avg_first_response_time"])
botSummary, err := svc.GetBotSummary(context.Background(), account.ID, since, until)
require.NoError(t, err)
assert.Equal(t, int64(1), botSummary.BotResolutionsCount)
assert.Equal(t, int64(0), botSummary.BotHandoffsCount)
assert.NotNil(t, botSummary.Previous)
botMetrics, err := svc.GetBotMetrics(context.Background(), account.ID, since, until)
require.NoError(t, err)
botMap := botMetrics.(map[string]interface{})
assert.Equal(t, int64(1), botMap["conversation_count"])
assert.Equal(t, int64(1), botMap["message_count"])
assert.Equal(t, 100, botMap["resolution_rate"])
distribution, err := svc.GetFirstResponseTimeDistribution(context.Background(), account.ID, since, until)
require.NoError(t, err)
distMap := distribution.(map[string]map[string]int64)
assert.Equal(t, int64(1), distMap["web_widget"]["0-1h"])
outgoing, err := svc.GetOutgoingMessagesCountGrouped(context.Background(), account.ID, since, until, "inbox")
require.NoError(t, err)
outRows := outgoing.([]map[string]interface{})
require.Len(t, outRows, 1)
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)
require.NoError(t, err)
matrixMap := matrix.(map[string]interface{})
assert.Equal(t, [][]int64{{1}}, matrixMap["matrix"])
}
+452
View File
@@ -0,0 +1,452 @@
package service
import (
"context"
"errors"
"sort"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
)
func (s *AnalyticsService) analyticsDB() (*gorm.DB, error) {
if s == nil || s.db == nil {
return nil, errors.New("analytics database is required")
}
return s.db, nil
}
func (s *AnalyticsService) liveConversationMetrics(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
base := func() *gorm.DB {
q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
if teamID > 0 {
q = q.Where("team_id = ?", teamID)
}
return q
}
var result ConversationMetrics
if err := base().Where("status = ?", string(model.ConversationStatusOpen)).Count(&result.OpenCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ? AND (first_reply_created_at IS NULL OR waiting_since IS NOT NULL)", string(model.ConversationStatusOpen)).Count(&result.UnattendedCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ? AND assignee_id IS NULL", string(model.ConversationStatusOpen)).Count(&result.UnassignedCount).Error; err != nil {
return nil, err
}
if err := base().Where("status = ?", string(model.ConversationStatusPending)).Count(&result.PendingCount).Error; err != nil {
return nil, err
}
return &result, nil
}
func (s *AnalyticsService) groupedLiveConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) {
if groupBy != "team_id" && groupBy != "assignee_id" {
return nil, errors.New("invalid group_by")
}
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
type row struct {
GroupID *uint
Count int64
}
load := func(where string) (map[uint]int64, error) {
var rows []row
if err := db.WithContext(ctx).Model(&model.Conversation{}).
Select(groupBy+" AS group_id, COUNT(*) AS count").
Where("account_id = ? AND status = ? "+where, accountID, string(model.ConversationStatusOpen)).
Group(groupBy).
Scan(&rows).Error; err != nil {
return nil, err
}
counts := map[uint]int64{}
for _, row := range rows {
if row.GroupID == nil {
counts[0] += row.Count
continue
}
counts[*row.GroupID] += row.Count
}
return counts, nil
}
open, err := load("")
if err != nil {
return nil, err
}
unattended, err := load("AND (first_reply_created_at IS NULL OR waiting_since IS NOT NULL)")
if err != nil {
return nil, err
}
unassigned, err := load("AND assignee_id IS NULL")
if err != nil {
return nil, err
}
ids := make([]uint, 0, len(open))
for id := range open {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
result := make([]map[string]interface{}, 0, len(ids))
for _, id := range ids {
metric := map[string]interface{}{
groupBy: id,
"open": open[id],
"unattended": unattended[id],
"unassigned": unassigned[id],
}
if id == 0 {
metric[groupBy] = nil
}
result = append(result, metric)
}
return result, nil
}
func (s *AnalyticsService) botSummaryCounts(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
var handoffIDs []uint
if err := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until).
Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return nil, err
}
result := &BotSummaryResponse{}
handoffQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, since, until)
if err := handoffQ.Distinct("conversation_id").Count(&result.BotHandoffsCount).Error; err != nil {
return nil, err
}
resolvedQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, since, until)
if len(handoffIDs) > 0 {
resolvedQ = resolvedQ.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs)
}
if err := resolvedQ.Distinct("conversation_id").Count(&result.BotResolutionsCount).Error; err != nil {
return nil, err
}
return result, nil
}
func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string) (interface{}, error) {
switch reportType {
case "account":
return s.liveConversationMetrics(ctx, accountID, 0)
case "agent":
return s.groupedLiveConversationMetrics(ctx, accountID, "assignee_id")
case "team":
return s.groupedLiveConversationMetrics(ctx, accountID, "team_id")
default:
return s.liveConversationMetrics(ctx, accountID, 0)
}
}
func (s *AnalyticsService) conversationSummary(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
countConversations := func(where string, args ...interface{}) (int64, error) {
var count int64
q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID).Where(where, args...)
err := q.Count(&count).Error
return count, err
}
countMessages := func(messageType model.MessageType) (int64, error) {
var count int64
err := db.WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, string(messageType), since, until).
Count(&count).Error
return count, err
}
conversations, err := countConversations("created_at >= ? AND created_at < ?", since, until)
if err != nil {
return nil, err
}
incoming, err := countMessages(model.MessageTypeIncoming)
if err != nil {
return nil, err
}
outgoing, err := countMessages(model.MessageTypeOutgoing)
if err != nil {
return nil, err
}
resolutions, err := countConversations("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until)
if err != nil {
return nil, err
}
firstResponse, _ := s.averageEventValue(ctx, accountID, []string{model.MetricNameFirstResponse}, since, until)
resolutionTime, _ := s.averageEventValue(ctx, accountID, []string{"conversation_resolved", model.MetricNameResolutionTime}, since, until)
replyTime, _ := s.averageEventValue(ctx, accountID, []string{model.MetricNameReplyTime}, since, until)
return map[string]interface{}{
"conversations_count": conversations,
"incoming_messages_count": incoming,
"outgoing_messages_count": outgoing,
"avg_first_response_time": firstResponse,
"avg_resolution_time": resolutionTime,
"resolutions_count": resolutions,
"reply_time": replyTime,
}, nil
}
func (s *AnalyticsService) botMetrics(ctx context.Context, accountID uint, since, until time.Time) (map[string]interface{}, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
var inboxIDs []uint
if db.Migrator().HasTable(&model.AgentBotInbox{}) {
if err := db.WithContext(ctx).Model(&model.AgentBotInbox{}).
Where("status = ?", model.AgentBotInboxActive).
Where("account_id = ? OR account_id IS NULL", accountID).
Distinct("inbox_id").Pluck("inbox_id", &inboxIDs).Error; err != nil {
return nil, err
}
}
conversationCount := int64(0)
messageCount := int64(0)
if len(inboxIDs) > 0 {
if err := db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND inbox_id IN ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, since, until).
Count(&conversationCount).Error; err != nil {
return nil, err
}
if err := db.WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND inbox_id IN ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, inboxIDs, string(model.MessageTypeOutgoing), since, until).
Count(&messageCount).Error; err != nil {
return nil, err
}
}
summary, err := s.botSummaryCounts(ctx, accountID, since, until)
if err != nil {
return nil, err
}
resolutionRate := 0
handoffRate := 0
if conversationCount > 0 {
resolutionRate = int(float64(summary.BotResolutionsCount) / float64(conversationCount) * 100)
handoffRate = int(float64(summary.BotHandoffsCount) / float64(conversationCount) * 100)
}
return map[string]interface{}{
"conversation_count": conversationCount,
"message_count": messageCount,
"resolution_rate": resolutionRate,
"handoff_rate": handoffRate,
}, nil
}
func (s *AnalyticsService) inboxLabelMatrix(ctx context.Context, accountID uint) (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 {
return nil, err
}
var tags []model.Tag
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("name ASC").Find(&tags).Error; err != nil {
return nil, err
}
type countRow struct {
InboxID uint
TagID uint
Count int64
}
var rows []countRow
if len(inboxes) > 0 && len(tags) > 0 {
if err := 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 {
return nil, err
}
}
counts := map[uint]map[uint]int64{}
for _, row := range rows {
if counts[row.InboxID] == nil {
counts[row.InboxID] = map[uint]int64{}
}
counts[row.InboxID][row.TagID] = row.Count
}
inboxPayload := make([]map[string]interface{}, 0, len(inboxes))
for _, inbox := range inboxes {
inboxPayload = append(inboxPayload, map[string]interface{}{"id": inbox.ID, "name": inbox.Name})
}
labelPayload := make([]map[string]interface{}, 0, len(tags))
for _, tag := range tags {
labelPayload = append(labelPayload, map[string]interface{}{"id": tag.ID, "title": tag.Name})
}
matrix := make([][]int64, 0, len(inboxes))
for _, inbox := range inboxes {
row := make([]int64, 0, len(tags))
for _, tag := range tags {
row = append(row, counts[inbox.ID][tag.ID])
}
matrix = append(matrix, row)
}
return map[string]interface{}{"inboxes": inboxPayload, "labels": labelPayload, "matrix": matrix}, nil
}
func (s *AnalyticsService) firstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (map[string]map[string]int64, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
var events []model.ReportingEvent
if err := db.WithContext(ctx).Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ?", accountID, model.MetricNameFirstResponse, since, until).Find(&events).Error; err != nil {
return nil, err
}
inboxIDs := make([]uint, 0, len(events))
for _, event := range events {
if event.InboxID != nil {
inboxIDs = append(inboxIDs, *event.InboxID)
}
}
var inboxes []model.Inbox
if len(inboxIDs) > 0 {
if err := db.WithContext(ctx).Where("account_id = ? AND id IN ?", accountID, inboxIDs).Find(&inboxes).Error; err != nil {
return nil, err
}
}
channelTypes := map[uint]string{}
for _, inbox := range inboxes {
channelTypes[inbox.ID] = inbox.ChannelType
}
result := map[string]map[string]int64{}
for _, event := range events {
if event.InboxID == nil {
continue
}
channelType := channelTypes[*event.InboxID]
if channelType == "" {
continue
}
if result[channelType] == nil {
result[channelType] = map[string]int64{"0-1h": 0, "1-4h": 0, "4-8h": 0, "8-24h": 0, "24h+": 0}
}
switch {
case event.Value < 3600:
result[channelType]["0-1h"]++
case event.Value < 14400:
result[channelType]["1-4h"]++
case event.Value < 28800:
result[channelType]["4-8h"]++
case event.Value < 86400:
result[channelType]["8-24h"]++
default:
result[channelType]["24h+"]++
}
}
return result, nil
}
func (s *AnalyticsService) outgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time, groupBy string) ([]map[string]interface{}, error) {
if groupBy == "" {
groupBy = "agent"
}
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
switch groupBy {
case "agent":
return s.outgoingMessagesByAgent(ctx, db, accountID, since, until)
case "team":
return s.outgoingMessagesByConversationField(ctx, db, accountID, since, until, "team_id", "teams")
case "inbox":
return s.outgoingMessagesByInbox(ctx, db, accountID, since, until)
case "label":
return s.outgoingMessagesByLabel(ctx, db, accountID, since, until)
default:
return nil, errors.New("invalid group_by")
}
}
func (s *AnalyticsService) averageEventValue(ctx context.Context, accountID uint, names []string, since, until time.Time) (float64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
var row struct {
Value float64
Count int64
}
err = db.WithContext(ctx).Model(&model.ReportingEvent{}).
Select("COALESCE(SUM(value), 0) AS value, COUNT(*) AS count").
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until).
Scan(&row).Error
if err != nil || row.Count == 0 {
return 0, err
}
return row.Value / float64(row.Count), nil
}
type outgoingCountRow struct {
ID uint
Name string
Count int64
}
func (s *AnalyticsService) outgoingMessagesByAgent(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
err := db.WithContext(ctx).Table("messages").
Select("messages.sender_id AS id, users.name AS name, COUNT(*) AS count").
Joins("LEFT JOIN users ON users.id = messages.sender_id").
Where("messages.account_id = ? AND messages.message_type = ? AND messages.sender_type = ? AND messages.sender_id IS NOT NULL AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), "User", since, until).
Group("messages.sender_id, users.name").Scan(&rows).Error
return outgoingRows(rows, "agent"), err
}
func (s *AnalyticsService) outgoingMessagesByConversationField(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time, field, table string) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
err := db.WithContext(ctx).Table("messages").
Select("conversations."+field+" AS id, "+table+".name AS name, COUNT(*) AS count").
Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id").
Joins("LEFT JOIN "+table+" ON "+table+".id = conversations."+field).
Where("messages.account_id = ? AND messages.message_type = ? AND conversations."+field+" IS NOT NULL AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until).
Group("conversations." + field + ", " + table + ".name").Scan(&rows).Error
return outgoingRows(rows, strings.TrimSuffix(field, "_id")), err
}
func (s *AnalyticsService) outgoingMessagesByInbox(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
err := db.WithContext(ctx).Table("messages").
Select("messages.inbox_id AS id, inboxes.name AS name, COUNT(*) AS count").
Joins("LEFT JOIN inboxes ON inboxes.id = messages.inbox_id").
Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until).
Group("messages.inbox_id, inboxes.name").Scan(&rows).Error
return outgoingRows(rows, "inbox"), err
}
func (s *AnalyticsService) outgoingMessagesByLabel(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) ([]map[string]interface{}, error) {
var rows []outgoingCountRow
err := db.WithContext(ctx).Table("messages").
Select("tags.id AS id, tags.name AS name, COUNT(*) AS count").
Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id").
Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = conversations.id").
Joins("INNER JOIN tags ON tags.id = conversation_labels.tag_id").
Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(model.MessageTypeOutgoing), since, until).
Group("tags.id, tags.name").Scan(&rows).Error
return outgoingRows(rows, "label"), err
}
func outgoingRows(rows []outgoingCountRow, _ string) []map[string]interface{} {
result := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
result = append(result, map[string]interface{}{"id": row.ID, "name": row.Name, "outgoing_messages_count": row.Count})
}
return result
}
+56 -46
View File
@@ -4,11 +4,10 @@ import (
"context"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
"gorm.io/gorm"
)
// AnalyticsService implements business logic for reporting/analytics.
@@ -16,15 +15,24 @@ import (
type AnalyticsService struct {
eventRepo *repository.ReportingEventRepo
rollupRepo *repository.ReportingEventsRollupRepo
db *gorm.DB
}
func NewAnalyticsService(
eventRepo *repository.ReportingEventRepo,
rollupRepo *repository.ReportingEventsRollupRepo,
) *AnalyticsService {
var db *gorm.DB
if rollupRepo != nil {
db = rollupRepo.DB()
}
if db == nil && eventRepo != nil {
db = eventRepo.DB()
}
return &AnalyticsService{
eventRepo: eventRepo,
rollupRepo: rollupRepo,
db: db,
}
}
@@ -32,9 +40,9 @@ func NewAnalyticsService(
// MetricSummary holds aggregated metric data.
type MetricSummary struct {
Metric string `json:"metric"`
Count int64 `json:"count"`
AverageValue float64 `json:"average_value"`
Metric string `json:"metric"`
Count int64 `json:"count"`
AverageValue float64 `json:"average_value"`
AverageBusinessHours float64 `json:"average_business_hours"`
}
@@ -52,8 +60,8 @@ type DimensionMetrics struct {
// ConversationTrafficPoint holds a single time-series point.
type ConversationTrafficPoint struct {
Date time.Time `json:"date"`
Count int64 `json:"count"`
Date time.Time `json:"date"`
Count int64 `json:"count"`
}
// ConversationMetrics holds real-time conversation counts.
@@ -92,9 +100,9 @@ func (s *AnalyticsService) GetSummary(ctx context.Context, accountID uint, since
m.AverageBusinessHours += r.SumValueBusinessHours
} else {
metricMap[key] = &MetricSummary{
Metric: key,
Count: r.Count,
AverageValue: r.SumValue,
Metric: key,
Count: r.Count,
AverageValue: r.SumValue,
AverageBusinessHours: r.SumValueBusinessHours,
}
}
@@ -146,9 +154,9 @@ func (s *AnalyticsService) getDimensionMetrics(ctx context.Context, accountID ui
for _, r := range rollups {
if dm, ok := grouped[r.DimensionID]; ok {
ms := MetricSummary{
Metric: string(r.Metric),
Count: r.Count,
AverageValue: r.SumValue,
Metric: string(r.Metric),
Count: r.Count,
AverageValue: r.SumValue,
AverageBusinessHours: r.SumValueBusinessHours,
}
if r.Count > 0 {
@@ -158,9 +166,9 @@ func (s *AnalyticsService) getDimensionMetrics(ctx context.Context, accountID ui
dm.Metrics = append(dm.Metrics, ms)
} else {
ms := MetricSummary{
Metric: string(r.Metric),
Count: r.Count,
AverageValue: r.SumValue,
Metric: string(r.Metric),
Count: r.Count,
AverageValue: r.SumValue,
AverageBusinessHours: r.SumValueBusinessHours,
}
if r.Count > 0 {
@@ -204,22 +212,17 @@ func (s *AnalyticsService) GetConversationTraffic(ctx context.Context, accountID
// GetConversationMetrics returns real-time conversation counts.
// Reference: Chatwoot live_reports#conversation_metrics — open, unattended, unassigned, pending
func (s *AnalyticsService) GetConversationMetrics(ctx context.Context, accountID uint) (*ConversationMetrics, error) {
// TODO: implement real-time open/unattended/unassigned/pending counts from conversations table
// Currently returns placeholder data until conversation queries are implemented
return &ConversationMetrics{
OpenCount: 0,
UnattendedCount: 0,
UnassignedCount: 0,
PendingCount: 0,
}, nil
return s.GetConversationMetricsForTeam(ctx, accountID, 0)
}
func (s *AnalyticsService) GetConversationMetricsForTeam(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) {
return s.liveConversationMetrics(ctx, accountID, teamID)
}
// GetGroupedConversationMetrics returns conversation metrics grouped by team_id or assignee_id.
// Reference: Chatwoot live_reports#grouped_conversation_metrics
func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]GroupedConversationMetric, error) {
// TODO: implement grouped conversation metrics from conversations table
// Currently returns empty until group-by queries are implemented
return []GroupedConversationMetric{}, nil
func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) {
return s.groupedLiveConversationMetrics(ctx, accountID, groupBy)
}
// RecordEvent records a new reporting event.
@@ -338,59 +341,66 @@ func (s *AnalyticsService) groupEventsByDimension(events []model.ReportingEvent,
}
return grouped
}
// GetBotSummary returns bot-level summary metrics.
// Reference: Chatwoot reports#bot_summary
func (s *AnalyticsService) GetBotSummary(ctx context.Context, accountID uint, since, until time.Time) (*BotSummaryResponse, error) {
// TODO: implement bot summary using V2::Reports::BotSummaryBuilder logic
return &BotSummaryResponse{}, nil
current, err := s.botSummaryCounts(ctx, accountID, since, until)
if err != nil {
return nil, err
}
previousSince := since.Add(-(until.Sub(since)))
previous, err := s.botSummaryCounts(ctx, accountID, previousSince, since)
if err != nil {
return nil, err
}
current.Previous = previous
return current, nil
}
// BotSummaryResponse holds bot summary metrics.
type BotSummaryResponse struct {
BotResolutions int64 `json:"bot_resolutions"`
BotHandoffs int64 `json:"bot_handoffs"`
SelfService int64 `json:"self_service"`
HumanService int64 `json:"human_service"`
BotResolutionsCount int64 `json:"bot_resolutions_count"`
BotHandoffsCount int64 `json:"bot_handoffs_count"`
Previous *BotSummaryResponse `json:"previous,omitempty"`
}
// GetConversationsByType returns conversation metrics filtered by report type.
// Reference: Chatwoot reports#conversations — type param is required
func (s *AnalyticsService) GetConversationsByType(ctx context.Context, accountID uint, reportType string, since, until time.Time) (interface{}, error) {
// TODO: implement conversation metrics by type using V2::Reports::Conversations::ConversationMetricsService
return gin.H{}, nil
return s.conversationMetricsByType(ctx, accountID, reportType)
}
// GetConversationsSummary returns conversations summary report.
// Reference: Chatwoot reports#conversations_summary
func (s *AnalyticsService) GetConversationsSummary(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
// TODO: implement conversations summary using V2::Reports::Conversations::SummaryBuilder
return gin.H{}, nil
return s.conversationSummary(ctx, accountID, since, until)
}
// GetBotMetrics returns bot metrics.
// Reference: Chatwoot reports#bot_metrics — V2::Reports::BotMetricsBuilder
func (s *AnalyticsService) GetBotMetrics(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
// TODO: implement bot metrics
return gin.H{}, nil
return s.botMetrics(ctx, accountID, since, until)
}
// 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) {
// TODO: implement inbox label matrix
return gin.H{}, nil
return s.inboxLabelMatrix(ctx, accountID)
}
// GetFirstResponseTimeDistribution returns first response time distribution.
// Reference: Chatwoot reports#first_response_time_distribution
func (s *AnalyticsService) GetFirstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
// TODO: implement FRT distribution
return gin.H{}, nil
return s.firstResponseTimeDistribution(ctx, accountID, since, until)
}
// GetOutgoingMessagesCount returns outgoing message count metrics.
// Reference: Chatwoot reports#outgoing_messages_count
func (s *AnalyticsService) GetOutgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
// TODO: implement outgoing messages count
return gin.H{}, nil
return s.outgoingMessagesCount(ctx, accountID, since, until, "")
}
func (s *AnalyticsService) GetOutgoingMessagesCountGrouped(ctx context.Context, accountID uint, since, until time.Time, groupBy string) (interface{}, error) {
return s.outgoingMessagesCount(ctx, accountID, since, until, groupBy)
}