Files
gochat/internal/service/analytics_query_helpers.go
T

981 lines
36 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, timezoneOffset float64, businessHours bool) ([]AnalyticsTimeseriesPoint, error) {
if strings.TrimSpace(groupBy) == "" {
groupBy = "day"
}
if reportType == "" {
reportType = "account"
}
loc := timeseriesLocation(timezoneOffset)
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, loc, false)
case "resolutions_count":
return s.conversationCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, true)
case "incoming_messages_count":
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, model.MessageTypeIncoming)
case "outgoing_messages_count":
return s.messageCountTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, model.MessageTypeOutgoing)
case "avg_first_response_time":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameFirstResponse}, true, businessHours, "")
case "avg_resolution_time":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_resolved", model.MetricNameResolutionTime}, true, businessHours, "")
case "reply_time":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{model.MetricNameReplyTime}, true, businessHours, "")
case "bot_resolutions_count":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, false, businessHours, "exclude_bot_handoffs")
case "bot_handoffs_count":
return s.eventTimeseries(ctx, accountID, since, until, reportType, id, groupBy, loc, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, false, businessHours, "distinct_conversation")
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, loc *time.Location, 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 := seedTimeseriesBuckets(since, until, groupBy, loc)
for _, conversation := range conversations {
t := conversation.CreatedAt
if resolved && conversation.ResolvedAt != nil {
t = *conversation.ResolvedAt
}
bucket := bucketStart(t, groupBy, loc)
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, loc *time.Location, 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 := seedTimeseriesBuckets(since, until, groupBy, loc)
for _, message := range messages {
point := ensureTimeseriesBucket(buckets, bucketStart(message.CreatedAt, groupBy, loc))
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, loc *time.Location, names []string, average bool, businessHours bool, countStrategy string) ([]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 !average && countStrategy == "exclude_bot_handoffs" {
var handoffIDs []uint
handoffQ := 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)
handoffQ = applyEventDimension(handoffQ, reportType, id)
if err := handoffQ.Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return nil, err
}
if len(handoffIDs) > 0 {
q = q.Where("conversation_id NOT IN ?", handoffIDs)
}
}
if err := q.Find(&events).Error; err != nil {
return nil, err
}
buckets := seedTimeseriesBuckets(since, until, groupBy, loc)
distinctConversationsByBucket := map[time.Time]map[uint]struct{}{}
for _, event := range events {
bucket := bucketStart(event.CreatedAt, groupBy, loc)
point := ensureTimeseriesBucket(buckets, bucket)
value := event.Value
if businessHours {
value = event.ValueInBusinessHours
}
if average {
point.Value += value
point.Count++
} else if countStrategy == "distinct_conversation" {
if event.ConversationID == nil {
continue
}
if distinctConversationsByBucket[bucket] == nil {
distinctConversationsByBucket[bucket] = map[uint]struct{}{}
}
if _, exists := distinctConversationsByBucket[bucket][*event.ConversationID]; exists {
continue
}
distinctConversationsByBucket[bucket][*event.ConversationID] = struct{}{}
point.Value++
} 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, loc *time.Location) time.Time {
if loc == nil {
loc = time.UTC
}
t = t.In(loc)
switch groupBy {
case "hour":
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, loc)
case "week":
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
return start.AddDate(0, 0, -int(start.Weekday()))
case "month":
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, loc)
case "year":
return time.Date(t.Year(), 1, 1, 0, 0, 0, 0, loc)
default:
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
}
func timeseriesLocation(offsetHours float64) *time.Location {
return time.FixedZone("report", int(offsetHours*3600))
}
func seedTimeseriesBuckets(since, until time.Time, groupBy string, loc *time.Location) map[time.Time]*AnalyticsTimeseriesPoint {
buckets := map[time.Time]*AnalyticsTimeseriesPoint{}
if since.IsZero() || until.IsZero() || !since.Before(until) {
return buckets
}
for bucket := bucketStart(since, groupBy, loc); bucket.Before(until.In(loc)); bucket = nextBucketStart(bucket, groupBy) {
ensureTimeseriesBucket(buckets, bucket)
}
return buckets
}
func nextBucketStart(bucket time.Time, groupBy string) time.Time {
switch groupBy {
case "hour":
return bucket.Add(time.Hour)
case "week":
return bucket.AddDate(0, 0, 7)
case "month":
return bucket.AddDate(0, 1, 0)
case "year":
return bucket.AddDate(1, 0, 0)
default:
return bucket.AddDate(0, 0, 1)
}
}
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, teamID uint) ([]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
q := db.WithContext(ctx).Model(&model.Conversation{}).
Select(groupBy+" AS group_id, COUNT(*) AS count").
Where("account_id = ? AND status = ? "+where, accountID, string(model.ConversationStatusOpen))
if teamID > 0 {
q = q.Where("team_id = ?", teamID)
}
if err := q.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, reportType string, id uint) (*BotSummaryResponse, error) {
result := &BotSummaryResponse{}
resolutions, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_bot_resolved", model.MetricNameBotResolutionsCount}, "exclude_bot_handoffs")
if err != nil {
return nil, err
}
handoffs, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_bot_handoff", model.MetricNameBotHandoffsCount}, "distinct_conversation")
if err != nil {
return nil, err
}
result.BotResolutionsCount = resolutions
result.BotHandoffsCount = handoffs
return result, nil
}
func (s *AnalyticsService) reportSummaryCounts(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, businessHours bool) (*ReportSummaryResponse, error) {
conversations, err := s.aggregateConversationCount(ctx, accountID, since, until, reportType, id, false)
if err != nil {
return nil, err
}
incoming, err := s.aggregateMessageCount(ctx, accountID, since, until, reportType, id, model.MessageTypeIncoming)
if err != nil {
return nil, err
}
outgoing, err := s.aggregateMessageCount(ctx, accountID, since, until, reportType, id, model.MessageTypeOutgoing)
if err != nil {
return nil, err
}
resolutions, err := s.aggregateEventCount(ctx, accountID, since, until, reportType, id, []string{"conversation_resolved", model.MetricNameResolutionTime}, "")
if err != nil {
return nil, err
}
firstResponse, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{model.MetricNameFirstResponse}, businessHours)
if err != nil {
return nil, err
}
resolutionTime, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{"conversation_resolved", model.MetricNameResolutionTime}, businessHours)
if err != nil {
return nil, err
}
replyTime, err := s.aggregateEventAverage(ctx, accountID, since, until, reportType, id, []string{model.MetricNameReplyTime}, businessHours)
if err != nil {
return nil, err
}
return &ReportSummaryResponse{
ConversationsCount: conversations,
IncomingMessagesCount: incoming,
OutgoingMessagesCount: outgoing,
AvgFirstResponseTime: firstResponse,
AvgResolutionTime: resolutionTime,
ResolutionsCount: resolutions,
ReplyTime: replyTime,
}, nil
}
func (s *AnalyticsService) aggregateConversationCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, resolved bool) (int64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
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)
var count int64
if err := q.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *AnalyticsService) aggregateMessageCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, messageType model.MessageType) (int64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
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)
var count int64
if err := q.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *AnalyticsService) aggregateEventAverage(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, names []string, businessHours bool) (float64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
valueColumn := "value"
if businessHours {
valueColumn = "value_in_business_hours"
}
var row struct {
Value float64
Count int64
}
q := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Select("COALESCE(SUM("+valueColumn+"), 0) AS value, COUNT(*) AS count").
Where("account_id = ? AND name IN ? AND created_at >= ? AND created_at < ?", accountID, names, since, until)
q = applyEventDimension(q, reportType, id)
if err := q.Scan(&row).Error; err != nil {
return 0, err
}
if row.Count == 0 {
return 0, nil
}
return row.Value / float64(row.Count), nil
}
func (s *AnalyticsService) aggregateEventCount(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, names []string, countStrategy string) (int64, error) {
db, err := s.analyticsDB()
if err != nil {
return 0, err
}
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 countStrategy == "exclude_bot_handoffs" {
var handoffIDs []uint
handoffQ := 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)
handoffQ = applyEventDimension(handoffQ, reportType, id)
if err := handoffQ.Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return 0, err
}
if len(handoffIDs) > 0 {
q = q.Where("conversation_id IS NULL OR conversation_id NOT IN ?", handoffIDs)
}
}
var count int64
if countStrategy == "distinct_conversation" {
if err := q.Where("conversation_id IS NOT NULL").Distinct("conversation_id").Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
if err := q.Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (s *AnalyticsService) conversationMetricsByType(ctx context.Context, accountID uint, reportType string, page int) (interface{}, error) {
if reportType == "account" {
return s.liveConversationMetrics(ctx, accountID, 0)
}
return s.agentConversationMetrics(ctx, accountID, page)
}
func (s *AnalyticsService) agentConversationMetrics(ctx context.Context, accountID uint, page int) ([]ReportAgentConversationMetric, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
if page < 1 {
page = 1
}
const perPage = 25
var accountUsers []model.AccountUser
if err := db.WithContext(ctx).
Preload("User").
Where("account_id = ?", accountID).
Order("id ASC").
Limit(perPage).
Offset((page - 1) * perPage).
Find(&accountUsers).Error; err != nil {
return nil, err
}
result := make([]ReportAgentConversationMetric, 0, len(accountUsers))
for _, accountUser := range accountUsers {
metric, metricErr := s.liveConversationMetricsForAssignee(ctx, accountID, accountUser.UserID)
if metricErr != nil {
return nil, metricErr
}
result = append(result, ReportAgentConversationMetric{
ID: accountUser.User.ID,
Name: accountUser.User.Name,
Email: accountUser.User.Email,
Thumbnail: accountUser.User.AvatarURL,
Availability: accountUser.Availability,
Metric: metric,
})
}
sort.SliceStable(result, func(i, j int) bool {
return result[i].Metric["open"] > result[j].Metric["open"]
})
return result, nil
}
func (s *AnalyticsService) liveConversationMetricsForAssignee(ctx context.Context, accountID uint, assigneeID uint) (map[string]int64, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
base := func() *gorm.DB {
return db.WithContext(ctx).Model(&model.Conversation{}).
Where("account_id = ? AND assignee_id = ? AND status = ?", accountID, assigneeID, string(model.ConversationStatusOpen))
}
var openCount int64
var unattendedCount int64
if err := base().Count(&openCount).Error; err != nil {
return nil, err
}
if err := base().Where("first_reply_created_at IS NULL OR waiting_since IS NOT NULL").Count(&unattendedCount).Error; err != nil {
return nil, err
}
return map[string]int64{"open": openCount, "unattended": unattendedCount}, nil
}
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
}
}
conversationIDs := []uint{}
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).
Pluck("id", &conversationIDs).Error; err != nil {
return nil, err
}
if len(conversationIDs) > 0 {
if err := db.WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND conversation_id IN ? AND message_type = ? AND created_at >= ? AND created_at < ?", accountID, conversationIDs, string(model.MessageTypeOutgoing), since, until).
Count(&messageCount).Error; err != nil {
return nil, err
}
}
}
conversationCount := int64(len(conversationIDs))
resolutions, handoffs, err := s.botMetricDistinctCounts(ctx, db, accountID, since, until)
if err != nil {
return nil, err
}
resolutionRate := 0
handoffRate := 0
if conversationCount > 0 {
resolutionRate = int(float64(resolutions) / float64(conversationCount) * 100)
handoffRate = int(float64(handoffs) / float64(conversationCount) * 100)
}
return map[string]interface{}{
"conversation_count": conversationCount,
"message_count": messageCount,
"resolution_rate": resolutionRate,
"handoff_rate": handoffRate,
}, nil
}
func (s *AnalyticsService) botMetricDistinctCounts(ctx context.Context, db *gorm.DB, accountID uint, since, until time.Time) (int64, int64, error) {
handoffIDs := []uint{}
if err := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, "conversation_bot_handoff", since, until).
Distinct("conversation_id").Pluck("conversation_id", &handoffIDs).Error; err != nil {
return 0, 0, err
}
resolutionQ := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Where("account_id = ? AND name = ? AND created_at >= ? AND created_at < ? AND conversation_id IS NOT NULL", accountID, "conversation_bot_resolved", since, until)
if len(handoffIDs) > 0 {
resolutionQ = resolutionQ.Where("conversation_id NOT IN ?", handoffIDs)
}
var resolutions int64
if err := resolutionQ.Distinct("conversation_id").Count(&resolutions).Error; err != nil {
return 0, 0, err
}
return resolutions, int64(len(handoffIDs)), 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
}