Files
gochat/internal/service/analytics_query_helpers.go
T

453 lines
17 KiB
Go

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
}