feat(reports): derive analytics aggregates
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user