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 }