722 lines
26 KiB
Go
722 lines
26 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type AnalyticsTimeseriesPoint struct {
|
|
Value float64 `json:"value"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
Count int64 `json:"count,omitempty"`
|
|
}
|
|
|
|
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) EnsureRollupsForRange(ctx context.Context, accountID uint, since, until time.Time) error {
|
|
if s == nil || s.rollups == nil || s.db == nil || since.IsZero() || until.IsZero() {
|
|
return nil
|
|
}
|
|
start := dayStart(since)
|
|
end := dayStart(until)
|
|
if until.After(end) {
|
|
end = end.AddDate(0, 0, 1)
|
|
}
|
|
for d := start; d.Before(end); d = d.AddDate(0, 0, 1) {
|
|
var count int64
|
|
if err := s.db.WithContext(ctx).Model(&model.ReportingEventsRollup{}).Where("account_id = ? AND date = ?", accountID, d).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
if err := s.rollups.ComputeDailyRollup(ctx, accountID, d); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AnalyticsService) GetTimeseries(ctx context.Context, accountID uint, metric string, since, until time.Time, reportType string, id uint, groupBy string, businessHours bool) ([]AnalyticsTimeseriesPoint, error) {
|
|
if strings.TrimSpace(groupBy) == "" {
|
|
groupBy = "day"
|
|
}
|
|
if reportType == "" {
|
|
reportType = "account"
|
|
}
|
|
if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil {
|
|
return nil, err
|
|
}
|
|
switch metric {
|
|
case "conversations_count":
|
|
return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, false)
|
|
case "resolutions_count":
|
|
return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, true)
|
|
case "incoming_messages_count":
|
|
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, model.MessageTypeIncoming)
|
|
case "outgoing_messages_count":
|
|
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, model.MessageTypeOutgoing)
|
|
case "avg_first_response_time":
|
|
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{model.MetricNameFirstResponse}, true, businessHours)
|
|
case "avg_resolution_time":
|
|
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_resolved", model.MetricNameResolutionTime}, true, businessHours)
|
|
case "reply_time":
|
|
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{model.MetricNameReplyTime}, true, businessHours)
|
|
case "bot_resolutions_count":
|
|
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, false, businessHours)
|
|
case "bot_handoffs_count":
|
|
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, false, businessHours)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported report metric %q", metric)
|
|
}
|
|
}
|
|
|
|
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) conversationCountTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, resolved bool) ([]AnalyticsTimeseriesPoint, error) {
|
|
db, err := s.analyticsDB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var conversations []model.Conversation
|
|
q := db.WithContext(ctx).Model(&model.Conversation{}).Where("account_id = ?", accountID)
|
|
if resolved {
|
|
q = q.Where("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until)
|
|
} else {
|
|
q = q.Where("created_at >= ? AND created_at < ?", since, until)
|
|
}
|
|
q = applyConversationDimension(q, reportType, id)
|
|
if err := q.Find(&conversations).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
buckets := map[time.Time]*AnalyticsTimeseriesPoint{}
|
|
for _, conversation := range conversations {
|
|
t := conversation.CreatedAt
|
|
if resolved && conversation.ResolvedAt != nil {
|
|
t = *conversation.ResolvedAt
|
|
}
|
|
bucket := bucketStart(t, groupBy)
|
|
point := ensureTimeseriesBucket(buckets, bucket)
|
|
point.Value++
|
|
}
|
|
return sortedTimeseries(buckets), nil
|
|
}
|
|
|
|
func (s *AnalyticsService) messageCountTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, messageType model.MessageType) ([]AnalyticsTimeseriesPoint, error) {
|
|
db, err := s.analyticsDB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var messages []model.Message
|
|
q := db.WithContext(ctx).Model(&model.Message{}).Where("messages.account_id = ? AND messages.message_type = ? AND messages.created_at >= ? AND messages.created_at < ?", accountID, string(messageType), since, until)
|
|
q = applyMessageDimension(q, reportType, id)
|
|
if err := q.Find(&messages).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
buckets := map[time.Time]*AnalyticsTimeseriesPoint{}
|
|
for _, message := range messages {
|
|
point := ensureTimeseriesBucket(buckets, bucketStart(message.CreatedAt, groupBy))
|
|
point.Value++
|
|
}
|
|
return sortedTimeseries(buckets), nil
|
|
}
|
|
|
|
func (s *AnalyticsService) eventTimeseries(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, groupBy string, names []string, average bool, businessHours bool) ([]AnalyticsTimeseriesPoint, error) {
|
|
db, err := s.analyticsDB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var events []model.ReportingEvent
|
|
q := db.WithContext(ctx).Model(&model.ReportingEvent{}).Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until)
|
|
q = applyEventDimension(q, reportType, id)
|
|
if err := q.Find(&events).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
buckets := map[time.Time]*AnalyticsTimeseriesPoint{}
|
|
for _, event := range events {
|
|
point := ensureTimeseriesBucket(buckets, bucketStart(event.CreatedAt, groupBy))
|
|
value := event.Value
|
|
if businessHours {
|
|
value = event.ValueInBusinessHours
|
|
}
|
|
if average {
|
|
point.Value += value
|
|
point.Count++
|
|
} else {
|
|
point.Value++
|
|
}
|
|
}
|
|
if average {
|
|
for _, point := range buckets {
|
|
if point.Count > 0 {
|
|
point.Value = point.Value / float64(point.Count)
|
|
}
|
|
}
|
|
}
|
|
return sortedTimeseries(buckets), nil
|
|
}
|
|
|
|
func applyConversationDimension(query *gorm.DB, reportType string, id uint) *gorm.DB {
|
|
switch reportType {
|
|
case "inbox":
|
|
if id > 0 {
|
|
query = query.Where("inbox_id = ?", id)
|
|
}
|
|
case "agent":
|
|
if id > 0 {
|
|
query = query.Where("assignee_id = ?", id)
|
|
}
|
|
case "team":
|
|
if id > 0 {
|
|
query = query.Where("team_id = ?", id)
|
|
}
|
|
case "label":
|
|
if id > 0 {
|
|
query = query.Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = conversations.id").Where("conversation_labels.tag_id = ?", id)
|
|
}
|
|
}
|
|
return query
|
|
}
|
|
|
|
func applyMessageDimension(query *gorm.DB, reportType string, id uint) *gorm.DB {
|
|
switch reportType {
|
|
case "inbox":
|
|
if id > 0 {
|
|
query = query.Where("messages.inbox_id = ?", id)
|
|
}
|
|
case "agent":
|
|
if id > 0 {
|
|
query = query.Where("messages.sender_type = ? AND messages.sender_id = ?", "User", id)
|
|
}
|
|
case "team":
|
|
if id > 0 {
|
|
query = query.Joins("INNER JOIN conversations ON conversations.id = messages.conversation_id").Where("conversations.team_id = ?", id)
|
|
}
|
|
case "label":
|
|
if id > 0 {
|
|
query = query.Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = messages.conversation_id").Where("conversation_labels.tag_id = ?", id)
|
|
}
|
|
}
|
|
return query
|
|
}
|
|
|
|
func applyEventDimension(query *gorm.DB, reportType string, id uint) *gorm.DB {
|
|
switch reportType {
|
|
case "inbox":
|
|
if id > 0 {
|
|
query = query.Where("inbox_id = ?", id)
|
|
}
|
|
case "agent":
|
|
if id > 0 {
|
|
query = query.Where("user_id = ?", id)
|
|
}
|
|
case "team":
|
|
if id > 0 {
|
|
query = query.Joins("INNER JOIN conversations ON conversations.id = reporting_events.conversation_id").Where("conversations.team_id = ?", id)
|
|
}
|
|
case "label":
|
|
if id > 0 {
|
|
query = query.Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = reporting_events.conversation_id").Where("conversation_labels.tag_id = ?", id)
|
|
}
|
|
}
|
|
return query
|
|
}
|
|
|
|
func dayStart(t time.Time) time.Time {
|
|
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
func bucketStart(t time.Time, groupBy string) time.Time {
|
|
t = t.UTC()
|
|
switch groupBy {
|
|
case "hour":
|
|
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, time.UTC)
|
|
case "week":
|
|
start := dayStart(t)
|
|
return start.AddDate(0, 0, -int(start.Weekday()))
|
|
case "month":
|
|
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
|
|
case "year":
|
|
return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, time.UTC)
|
|
default:
|
|
return dayStart(t)
|
|
}
|
|
}
|
|
|
|
func ensureTimeseriesBucket(buckets map[time.Time]*AnalyticsTimeseriesPoint, bucket time.Time) *AnalyticsTimeseriesPoint {
|
|
point := buckets[bucket]
|
|
if point == nil {
|
|
point = &AnalyticsTimeseriesPoint{Timestamp: bucket.Unix()}
|
|
buckets[bucket] = point
|
|
}
|
|
return point
|
|
}
|
|
|
|
func sortedTimeseries(buckets map[time.Time]*AnalyticsTimeseriesPoint) []AnalyticsTimeseriesPoint {
|
|
keys := make([]time.Time, 0, len(buckets))
|
|
for key := range buckets {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Slice(keys, func(i, j int) bool { return keys[i].Before(keys[j]) })
|
|
result := make([]AnalyticsTimeseriesPoint, 0, len(keys))
|
|
for _, key := range keys {
|
|
result = append(result, *buckets[key])
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseReportDimensionID(raw string) uint {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return 0
|
|
}
|
|
parsed, err := strconv.ParseUint(raw, 10, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return uint(parsed)
|
|
}
|
|
|
|
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
|
|
}
|