795 lines
32 KiB
Go
795 lines
32 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
"github.com/gochat/gochat/internal/worker"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// AnalyticsService implements business logic for reporting/analytics.
|
|
// Reference: Chatwoot app/controllers/api/v1/reports_controller.rb
|
|
type AnalyticsService struct {
|
|
eventRepo *repository.ReportingEventRepo
|
|
rollupRepo *repository.ReportingEventsRollupRepo
|
|
db *gorm.DB
|
|
rollups *ReportingRollupService
|
|
worker *worker.WorkerPool
|
|
}
|
|
|
|
func NewAnalyticsService(
|
|
eventRepo *repository.ReportingEventRepo,
|
|
rollupRepo *repository.ReportingEventsRollupRepo,
|
|
) *AnalyticsService {
|
|
var db *gorm.DB
|
|
if rollupRepo != nil {
|
|
db = rollupRepo.DB()
|
|
}
|
|
if db == nil && eventRepo != nil {
|
|
db = eventRepo.DB()
|
|
}
|
|
return &AnalyticsService{
|
|
eventRepo: eventRepo,
|
|
rollupRepo: rollupRepo,
|
|
db: db,
|
|
rollups: NewReportingRollupService(eventRepo, rollupRepo),
|
|
}
|
|
}
|
|
|
|
func (s *AnalyticsService) SetWorkerPool(wp *worker.WorkerPool) {
|
|
s.worker = wp
|
|
RegisterReportingRollupJobs(wp, s)
|
|
}
|
|
|
|
// --- Response DTOs ---
|
|
|
|
// MetricSummary holds aggregated metric data.
|
|
type MetricSummary struct {
|
|
Metric string `json:"metric"`
|
|
Count int64 `json:"count"`
|
|
AverageValue float64 `json:"average_value"`
|
|
AverageBusinessHours float64 `json:"average_business_hours"`
|
|
}
|
|
|
|
// SummaryResponse is the top-level reports/summary response.
|
|
type SummaryResponse struct {
|
|
Metrics []MetricSummary `json:"metrics"`
|
|
}
|
|
|
|
// DimensionMetrics holds metrics grouped by a dimension (agent/inbox/team).
|
|
type DimensionMetrics struct {
|
|
DimensionID uint `json:"dimension_id"`
|
|
DimensionType string `json:"dimension_type"`
|
|
Metrics []MetricSummary `json:"metrics"`
|
|
}
|
|
|
|
// ConversationTrafficPoint holds a single time-series point.
|
|
type ConversationTrafficPoint struct {
|
|
Date time.Time `json:"date"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
// ConversationMetrics holds real-time conversation counts.
|
|
// Reference: Chatwoot live_reports#conversation_metrics — { open, unattended, unassigned, pending }
|
|
type ConversationMetrics struct {
|
|
OpenCount int64 `json:"open"`
|
|
UnattendedCount int64 `json:"unattended"`
|
|
UnassignedCount int64 `json:"unassigned"`
|
|
PendingCount int64 `json:"pending"`
|
|
}
|
|
|
|
// ReportSummaryResponse is the Chatwoot v2 reports/summary payload.
|
|
// Reference: V2::Reports::Conversations::MetricBuilder#summary plus
|
|
// Api::V2::Accounts::ReportsController#build_summary.
|
|
type ReportSummaryResponse struct {
|
|
ConversationsCount int64 `json:"conversations_count"`
|
|
IncomingMessagesCount int64 `json:"incoming_messages_count"`
|
|
OutgoingMessagesCount int64 `json:"outgoing_messages_count"`
|
|
AvgFirstResponseTime float64 `json:"avg_first_response_time"`
|
|
AvgResolutionTime float64 `json:"avg_resolution_time"`
|
|
ResolutionsCount int64 `json:"resolutions_count"`
|
|
ReplyTime float64 `json:"reply_time"`
|
|
Previous *ReportSummaryResponse `json:"previous,omitempty"`
|
|
}
|
|
|
|
// GroupedConversationMetric holds conversation metrics grouped by team_id or assignee_id.
|
|
// Reference: Chatwoot live_reports#grouped_conversation_metrics
|
|
type GroupedConversationMetric struct {
|
|
GroupID uint `json:"group_id"`
|
|
OpenCount int64 `json:"open"`
|
|
UnattendedCount int64 `json:"unattended"`
|
|
UnassignedCount int64 `json:"unassigned"`
|
|
}
|
|
|
|
// ReportAgentConversationMetric is the legacy reports/conversations agent payload.
|
|
// Reference: V2::ReportBuilder#agent_metrics.
|
|
type ReportAgentConversationMetric struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Thumbnail string `json:"thumbnail"`
|
|
Availability string `json:"availability"`
|
|
Metric map[string]int64 `json:"metric"`
|
|
}
|
|
|
|
// InboxLabelMatrixFilter mirrors V2::Reports::InboxLabelMatrixBuilder params.
|
|
type InboxLabelMatrixFilter struct {
|
|
Since time.Time
|
|
Until time.Time
|
|
InboxIDs []uint
|
|
LabelIDs []uint
|
|
}
|
|
|
|
type ReportDrilldownParams struct {
|
|
Metric, DimensionType, GroupBy string
|
|
DimensionID uint
|
|
Since, Until, BucketTimestamp time.Time
|
|
TimezoneOffset float64
|
|
BusinessHours bool
|
|
Page, PerPage int
|
|
}
|
|
|
|
type ReportDrilldownResult struct {
|
|
Meta map[string]any `json:"meta"`
|
|
Payload []map[string]any `json:"payload"`
|
|
}
|
|
|
|
var reportDrilldownMetrics = map[string]string{
|
|
"conversations_count": "", "incoming_messages_count": "", "outgoing_messages_count": "",
|
|
"avg_first_response_time": "first_response", "avg_resolution_time": "conversation_resolved",
|
|
"reply_time": "reply_time", "resolutions_count": "conversation_resolved",
|
|
"bot_resolutions_count": "conversation_bot_resolved", "bot_handoffs_count": "conversation_bot_handoff",
|
|
}
|
|
|
|
// GetSummary returns account-level aggregated metrics for a date range.
|
|
func (s *AnalyticsService) GetSummary(ctx context.Context, accountID uint, since, until time.Time) (*SummaryResponse, error) {
|
|
if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil {
|
|
return nil, err
|
|
}
|
|
rollups, err := s.rollupRepo.FindByAccountAndDateRange(ctx, accountID, since, until)
|
|
if err != nil {
|
|
applogger.L().Errorf("GetSummary rollup query: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
// Aggregate by metric
|
|
metricMap := map[string]*MetricSummary{}
|
|
for _, r := range rollups {
|
|
key := string(r.Metric)
|
|
if m, ok := metricMap[key]; ok {
|
|
m.Count += r.Count
|
|
m.AverageValue += r.SumValue
|
|
m.AverageBusinessHours += r.SumValueBusinessHours
|
|
} else {
|
|
metricMap[key] = &MetricSummary{
|
|
Metric: key,
|
|
Count: r.Count,
|
|
AverageValue: r.SumValue,
|
|
AverageBusinessHours: r.SumValueBusinessHours,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compute averages
|
|
metrics := make([]MetricSummary, 0, len(metricMap))
|
|
for _, m := range metricMap {
|
|
if m.Count > 0 {
|
|
m.AverageValue = m.AverageValue / float64(m.Count)
|
|
m.AverageBusinessHours = m.AverageBusinessHours / float64(m.Count)
|
|
}
|
|
metrics = append(metrics, *m)
|
|
}
|
|
|
|
return &SummaryResponse{Metrics: metrics}, nil
|
|
}
|
|
|
|
// GetDrilldown returns the raw records behind one chart bucket using the exact
|
|
// meta/payload envelope consumed by Chatwoot's report drilldown drawer.
|
|
func (s *AnalyticsService) GetDrilldown(ctx context.Context, accountID uint, params ReportDrilldownParams) (*ReportDrilldownResult, error) {
|
|
rawEvent, supported := reportDrilldownMetrics[params.Metric]
|
|
if !supported {
|
|
return nil, fmt.Errorf("unsupported metric")
|
|
}
|
|
if params.Page < 1 {
|
|
params.Page = 1
|
|
}
|
|
if params.PerPage <= 0 {
|
|
params.PerPage = 25
|
|
}
|
|
if params.PerPage > 100 {
|
|
params.PerPage = 100
|
|
}
|
|
if params.GroupBy == "" {
|
|
params.GroupBy = "day"
|
|
}
|
|
if err := s.validateReportDimension(ctx, accountID, params); err != nil {
|
|
return nil, err
|
|
}
|
|
bucketEnd := reportBucketEnd(params.BucketTimestamp, params.GroupBy, params.TimezoneOffset)
|
|
bucketStart := params.BucketTimestamp
|
|
if bucketStart.Before(params.Since) {
|
|
bucketStart = params.Since
|
|
}
|
|
if bucketEnd.After(params.Until) {
|
|
bucketEnd = params.Until
|
|
}
|
|
if !bucketEnd.After(bucketStart) {
|
|
return nil, fmt.Errorf("invalid bucket range")
|
|
}
|
|
|
|
recordType := "conversation"
|
|
var payload []map[string]any
|
|
var total, conversationCount int64
|
|
if params.Metric == "incoming_messages_count" || params.Metric == "outgoing_messages_count" {
|
|
recordType = "message"
|
|
messageType := "incoming"
|
|
if params.Metric == "outgoing_messages_count" {
|
|
messageType = "outgoing"
|
|
}
|
|
messageScope := func() *gorm.DB {
|
|
return s.reportMessageDimensionQuery(ctx, accountID, params).Where("messages.created_at >= ? AND messages.created_at < ? AND messages.message_type = ?", bucketStart, bucketEnd, messageType)
|
|
}
|
|
if err := messageScope().Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := messageScope().Distinct("messages.conversation_id").Count(&conversationCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var messages []model.Message
|
|
if err := messageScope().Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Order("messages.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&messages).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
payload = make([]map[string]any, 0, len(messages))
|
|
for i := range messages {
|
|
payload = append(payload, s.reportMessageRecord(ctx, &messages[i], nil, nil))
|
|
}
|
|
} else if params.Metric == "conversations_count" {
|
|
conversationScope := func() *gorm.DB {
|
|
return s.reportConversationDimensionQuery(ctx, accountID, params).Where("conversations.created_at >= ? AND conversations.created_at < ?", bucketStart, bucketEnd)
|
|
}
|
|
if err := conversationScope().Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
conversationCount = total
|
|
var conversations []model.Conversation
|
|
if err := conversationScope().Preload("Contact").Preload("Inbox").Preload("Assignee").Order("conversations.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&conversations).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
payload = make([]map[string]any, 0, len(conversations))
|
|
for i := range conversations {
|
|
payload = append(payload, s.reportConversationRecord(ctx, &conversations[i], nil, nil, ""))
|
|
}
|
|
} else {
|
|
eventScope := func() *gorm.DB {
|
|
q := s.reportEventDimensionQuery(ctx, accountID, params).Where("reporting_events.name = ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", rawEvent, bucketStart, bucketEnd)
|
|
if params.Metric == "bot_resolutions_count" {
|
|
handoffs := s.reportEventDimensionQuery(ctx, accountID, params).Select("reporting_events.conversation_id").Where("reporting_events.name = ? AND reporting_events.created_at BETWEEN ? AND ? AND reporting_events.conversation_id IS NOT NULL", "conversation_bot_handoff", params.Since, params.Until)
|
|
q = q.Where("reporting_events.conversation_id NOT IN (?)", handoffs)
|
|
}
|
|
if params.Metric == "bot_handoffs_count" {
|
|
distinctEvents := s.reportEventDimensionQuery(ctx, accountID, params).Select("MAX(reporting_events.id)").Where("reporting_events.name = ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ? AND reporting_events.conversation_id IS NOT NULL", rawEvent, bucketStart, bucketEnd).Group("reporting_events.conversation_id")
|
|
q = q.Where("reporting_events.id IN (?)", distinctEvents)
|
|
}
|
|
return q
|
|
}
|
|
if err := eventScope().Count(&total).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := eventScope().Distinct("reporting_events.conversation_id").Count(&conversationCount).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var events []model.ReportingEvent
|
|
if err := eventScope().Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Order("reporting_events.created_at DESC").Offset((params.Page - 1) * params.PerPage).Limit(params.PerPage).Find(&events).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if params.Metric == "avg_first_response_time" || params.Metric == "reply_time" {
|
|
recordType = "message"
|
|
}
|
|
payload = make([]map[string]any, 0, len(events))
|
|
for i := range events {
|
|
payload = append(payload, s.reportEventRecord(ctx, &events[i], params))
|
|
}
|
|
}
|
|
return &ReportDrilldownResult{Meta: map[string]any{"metric": params.Metric, "record_type": recordType, "bucket": map[string]any{"since": bucketStart.Unix(), "until": bucketEnd.Unix()}, "current_page": params.Page, "per_page": params.PerPage, "total_count": total, "conversation_count": conversationCount}, Payload: payload}, nil
|
|
}
|
|
|
|
func (s *AnalyticsService) validateReportDimension(ctx context.Context, accountID uint, params ReportDrilldownParams) error {
|
|
if params.DimensionType == "account" {
|
|
return nil
|
|
}
|
|
var count int64
|
|
q := s.db.WithContext(ctx)
|
|
switch params.DimensionType {
|
|
case "inbox":
|
|
q = q.Model(&model.Inbox{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID)
|
|
case "agent":
|
|
q = q.Model(&model.AccountUser{}).Where("user_id = ? AND account_id = ?", params.DimensionID, accountID)
|
|
case "label":
|
|
q = q.Model(&model.Tag{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID)
|
|
case "team":
|
|
q = q.Model(&model.Team{}).Where("id = ? AND account_id = ?", params.DimensionID, accountID)
|
|
default:
|
|
return fmt.Errorf("unsupported dimension type")
|
|
}
|
|
if err := q.Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return gorm.ErrRecordNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AnalyticsService) reportMessageDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB {
|
|
q := s.db.WithContext(ctx).Model(&model.Message{}).Where("messages.account_id = ?", accountID)
|
|
switch params.DimensionType {
|
|
case "inbox":
|
|
q = q.Where("messages.inbox_id = ?", params.DimensionID)
|
|
case "agent":
|
|
q = q.Where("messages.sender_id = ? AND messages.sender_type IN ?", params.DimensionID, []string{"User", "agent", "user"})
|
|
case "team":
|
|
q = q.Joins("JOIN conversations drilldown_conversations ON drilldown_conversations.id = messages.conversation_id").Where("drilldown_conversations.team_id = ?", params.DimensionID)
|
|
case "label":
|
|
q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = messages.conversation_id").Where("drilldown_labels.tag_id = ?", params.DimensionID)
|
|
}
|
|
return q
|
|
}
|
|
|
|
func (s *AnalyticsService) reportConversationDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB {
|
|
q := s.db.WithContext(ctx).Model(&model.Conversation{}).Where("conversations.account_id = ?", accountID)
|
|
switch params.DimensionType {
|
|
case "inbox":
|
|
q = q.Where("conversations.inbox_id = ?", params.DimensionID)
|
|
case "agent":
|
|
q = q.Where("conversations.assignee_id = ?", params.DimensionID)
|
|
case "team":
|
|
q = q.Where("conversations.team_id = ?", params.DimensionID)
|
|
case "label":
|
|
q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = conversations.id").Where("drilldown_labels.tag_id = ?", params.DimensionID)
|
|
}
|
|
return q
|
|
}
|
|
|
|
func (s *AnalyticsService) reportEventDimensionQuery(ctx context.Context, accountID uint, params ReportDrilldownParams) *gorm.DB {
|
|
q := s.db.WithContext(ctx).Model(&model.ReportingEvent{}).Where("reporting_events.account_id = ?", accountID)
|
|
switch params.DimensionType {
|
|
case "inbox":
|
|
q = q.Where("reporting_events.inbox_id = ?", params.DimensionID)
|
|
case "agent":
|
|
q = q.Where("reporting_events.user_id = ?", params.DimensionID)
|
|
case "team":
|
|
q = q.Joins("JOIN conversations drilldown_conversations ON drilldown_conversations.id = reporting_events.conversation_id").Where("drilldown_conversations.team_id = ?", params.DimensionID)
|
|
case "label":
|
|
q = q.Joins("JOIN conversation_labels drilldown_labels ON drilldown_labels.conversation_id = reporting_events.conversation_id").Where("drilldown_labels.tag_id = ?", params.DimensionID)
|
|
}
|
|
return q
|
|
}
|
|
|
|
func (s *AnalyticsService) reportEventRecord(ctx context.Context, event *model.ReportingEvent, params ReportDrilldownParams) map[string]any {
|
|
metricValue := event.Value
|
|
if params.BusinessHours {
|
|
metricValue = event.ValueInBusinessHours
|
|
}
|
|
occurredAt := event.CreatedAt
|
|
if !event.EventEndTime.IsZero() {
|
|
occurredAt = event.EventEndTime
|
|
}
|
|
if params.Metric == "avg_first_response_time" || params.Metric == "reply_time" {
|
|
var message model.Message
|
|
q := s.db.WithContext(ctx).Preload("Conversation.Contact").Preload("Conversation.Inbox").Preload("Conversation.Assignee").Where("account_id = ? AND conversation_id = ? AND message_type IN ?", event.AccountID, event.ConversationID, []string{"outgoing", "template"})
|
|
if !event.EventEndTime.IsZero() {
|
|
q = q.Where("created_at BETWEEN ? AND ?", event.EventEndTime.Add(-time.Second), event.EventEndTime.Add(time.Second))
|
|
}
|
|
if err := q.Order("created_at DESC, id DESC").First(&message).Error; err == nil {
|
|
return s.reportMessageRecord(ctx, &message, &metricValue, &occurredAt)
|
|
}
|
|
}
|
|
return s.reportConversationRecord(ctx, event.Conversation, &metricValue, &occurredAt, event.Name)
|
|
}
|
|
|
|
func (s *AnalyticsService) reportMessageRecord(ctx context.Context, message *model.Message, metricValue *float64, occurredAt *time.Time) map[string]any {
|
|
when := message.CreatedAt
|
|
if occurredAt != nil {
|
|
when = *occurredAt
|
|
}
|
|
return map[string]any{"record_type": "message", "conversation": s.reportConversationAttributes(ctx, message.Conversation), "message": map[string]any{"id": message.ID, "content": message.Content, "message_type": message.MessageType, "sender_name": s.reportSenderName(ctx, message), "created_at": message.CreatedAt.Unix()}, "metric_value": metricValue, "occurred_at": when.Unix()}
|
|
}
|
|
|
|
func (s *AnalyticsService) reportConversationRecord(ctx context.Context, conversation *model.Conversation, metricValue *float64, occurredAt *time.Time, eventName string) map[string]any {
|
|
when := int64(0)
|
|
if conversation != nil {
|
|
when = conversation.CreatedAt.Unix()
|
|
}
|
|
if occurredAt != nil {
|
|
when = occurredAt.Unix()
|
|
}
|
|
record := map[string]any{"record_type": "conversation", "conversation": s.reportConversationAttributes(ctx, conversation), "message": nil, "metric_value": metricValue, "occurred_at": when}
|
|
if eventName != "" {
|
|
record["event_name"] = eventName
|
|
}
|
|
return record
|
|
}
|
|
|
|
func (s *AnalyticsService) reportConversationAttributes(ctx context.Context, conversation *model.Conversation) map[string]any {
|
|
if conversation == nil || conversation.ID == 0 {
|
|
return map[string]any{}
|
|
}
|
|
var last model.Message
|
|
_ = s.db.WithContext(ctx).Where("conversation_id = ? AND message_type <> ?", conversation.ID, "activity").Order("created_at DESC, id DESC").First(&last).Error
|
|
var lastPayload any
|
|
if last.ID != 0 {
|
|
lastPayload = map[string]any{"id": last.ID, "content": last.Content, "message_type": last.MessageType, "sender_name": s.reportSenderName(ctx, &last), "created_at": last.CreatedAt.Unix()}
|
|
}
|
|
assigneeName := ""
|
|
if conversation.Assignee != nil {
|
|
assigneeName = conversation.Assignee.Name
|
|
}
|
|
return map[string]any{"id": conversation.ID, "display_id": conversation.DisplayID, "contact_id": conversation.ContactID, "contact_name": conversation.Contact.Name, "inbox_id": conversation.InboxID, "inbox_name": conversation.Inbox.Name, "assignee_id": conversation.AssigneeID, "assignee_name": assigneeName, "status": conversation.Status, "created_at": conversation.CreatedAt.Unix(), "last_activity_at": reportInt64Value(conversation.LastActivityAt), "last_message": lastPayload}
|
|
}
|
|
|
|
func (s *AnalyticsService) reportSenderName(ctx context.Context, message *model.Message) any {
|
|
if message.SenderID == nil {
|
|
return nil
|
|
}
|
|
if message.SenderType == "Captain::Assistant" || message.SenderType == "CaptainAssistant" || message.SenderType == "captain_assistant" {
|
|
var assistant model.CaptainAssistant
|
|
if s.db.WithContext(ctx).First(&assistant, *message.SenderID).Error == nil {
|
|
return assistant.Name
|
|
}
|
|
return nil
|
|
}
|
|
if message.SenderType == "Contact" || message.SenderType == "contact" {
|
|
var contact model.Contact
|
|
if s.db.WithContext(ctx).First(&contact, *message.SenderID).Error == nil {
|
|
return contact.Name
|
|
}
|
|
}
|
|
var user model.User
|
|
if s.db.WithContext(ctx).First(&user, *message.SenderID).Error == nil {
|
|
return user.Name
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func reportBucketEnd(start time.Time, groupBy string, timezoneOffset float64) time.Time {
|
|
location := time.FixedZone("report", int(timezoneOffset*3600))
|
|
start = start.In(location)
|
|
switch groupBy {
|
|
case "hour":
|
|
return start.Add(time.Hour).UTC()
|
|
case "week":
|
|
return start.AddDate(0, 0, 7).UTC()
|
|
case "month":
|
|
return start.AddDate(0, 1, 0).UTC()
|
|
case "year":
|
|
return start.AddDate(1, 0, 0).UTC()
|
|
default:
|
|
return start.AddDate(0, 0, 1).UTC()
|
|
}
|
|
}
|
|
func reportInt64Value(value *int64) int64 {
|
|
if value == nil {
|
|
return 0
|
|
}
|
|
return *value
|
|
}
|
|
|
|
// GetAgentMetrics returns metrics grouped by agent dimension.
|
|
func (s *AnalyticsService) GetAgentMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) {
|
|
return s.getDimensionMetrics(ctx, accountID, model.DimensionAgent, since, until)
|
|
}
|
|
|
|
// GetInboxMetrics returns metrics grouped by inbox dimension.
|
|
func (s *AnalyticsService) GetInboxMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) {
|
|
return s.getDimensionMetrics(ctx, accountID, model.DimensionInbox, since, until)
|
|
}
|
|
|
|
// GetLabelMetrics returns metrics grouped by label dimension.
|
|
// Note: Chatwoot uses labels as a special dimension; we approximate with account-level rollups.
|
|
func (s *AnalyticsService) GetLabelMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) {
|
|
return s.getDimensionMetrics(ctx, accountID, model.DimensionAccount, since, until)
|
|
}
|
|
|
|
// GetTeamMetrics returns metrics grouped by team dimension.
|
|
func (s *AnalyticsService) GetTeamMetrics(ctx context.Context, accountID uint, since, until time.Time) ([]DimensionMetrics, error) {
|
|
return s.getDimensionMetrics(ctx, accountID, model.DimensionTeam, since, until)
|
|
}
|
|
|
|
func (s *AnalyticsService) getDimensionMetrics(ctx context.Context, accountID uint, dimType model.DimensionType, since, until time.Time) ([]DimensionMetrics, error) {
|
|
if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil {
|
|
return nil, err
|
|
}
|
|
rollups, err := s.rollupRepo.AggregateSummary(ctx, accountID, dimType, since, until)
|
|
if err != nil {
|
|
applogger.L().Errorf("getDimensionMetrics query: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
// Group by dimension_id
|
|
grouped := map[uint]*DimensionMetrics{}
|
|
for _, r := range rollups {
|
|
if dm, ok := grouped[r.DimensionID]; ok {
|
|
ms := MetricSummary{
|
|
Metric: string(r.Metric),
|
|
Count: r.Count,
|
|
AverageValue: r.SumValue,
|
|
AverageBusinessHours: r.SumValueBusinessHours,
|
|
}
|
|
if r.Count > 0 {
|
|
ms.AverageValue = r.SumValue / float64(r.Count)
|
|
ms.AverageBusinessHours = r.SumValueBusinessHours / float64(r.Count)
|
|
}
|
|
dm.Metrics = append(dm.Metrics, ms)
|
|
} else {
|
|
ms := MetricSummary{
|
|
Metric: string(r.Metric),
|
|
Count: r.Count,
|
|
AverageValue: r.SumValue,
|
|
AverageBusinessHours: r.SumValueBusinessHours,
|
|
}
|
|
if r.Count > 0 {
|
|
ms.AverageValue = r.SumValue / float64(r.Count)
|
|
ms.AverageBusinessHours = r.SumValueBusinessHours / float64(r.Count)
|
|
}
|
|
grouped[r.DimensionID] = &DimensionMetrics{
|
|
DimensionID: r.DimensionID,
|
|
DimensionType: string(r.DimensionType),
|
|
Metrics: []MetricSummary{ms},
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make([]DimensionMetrics, 0, len(grouped))
|
|
for _, dm := range grouped {
|
|
result = append(result, *dm)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetConversationTraffic returns daily conversation count time-series.
|
|
func (s *AnalyticsService) GetConversationTraffic(ctx context.Context, accountID uint, since, until time.Time) ([]ConversationTrafficPoint, error) {
|
|
if err := s.EnsureRollupsForRange(ctx, accountID, since, until); err != nil {
|
|
return nil, err
|
|
}
|
|
rollups, err := s.rollupRepo.FindByMetric(ctx, accountID, model.MetricResolutionsCount, since, until)
|
|
if err != nil {
|
|
applogger.L().Errorf("GetConversationTraffic query: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
points := make([]ConversationTrafficPoint, 0, len(rollups))
|
|
for _, r := range rollups {
|
|
points = append(points, ConversationTrafficPoint{
|
|
Date: r.Date,
|
|
Count: r.Count,
|
|
})
|
|
}
|
|
return points, nil
|
|
}
|
|
|
|
// GetConversationMetrics returns real-time conversation counts.
|
|
// GetConversationMetrics returns real-time conversation counts.
|
|
// Reference: Chatwoot live_reports#conversation_metrics — open, unattended, unassigned, pending
|
|
func (s *AnalyticsService) GetConversationMetrics(ctx context.Context, accountID uint) (*ConversationMetrics, error) {
|
|
return s.GetConversationMetricsForTeam(ctx, accountID, 0)
|
|
}
|
|
|
|
func (s *AnalyticsService) GetConversationMetricsForTeam(ctx context.Context, accountID uint, teamID uint) (*ConversationMetrics, error) {
|
|
return s.liveConversationMetrics(ctx, accountID, teamID)
|
|
}
|
|
|
|
// GetGroupedConversationMetrics returns conversation metrics grouped by team_id or assignee_id.
|
|
// Reference: Chatwoot live_reports#grouped_conversation_metrics
|
|
func (s *AnalyticsService) GetGroupedConversationMetrics(ctx context.Context, accountID uint, groupBy string) ([]map[string]interface{}, error) {
|
|
return s.GetGroupedConversationMetricsForTeam(ctx, accountID, groupBy, 0)
|
|
}
|
|
|
|
func (s *AnalyticsService) GetGroupedConversationMetricsForTeam(ctx context.Context, accountID uint, groupBy string, teamID uint) ([]map[string]interface{}, error) {
|
|
return s.groupedLiveConversationMetrics(ctx, accountID, groupBy, teamID)
|
|
}
|
|
|
|
// GetReportSummary returns the Chatwoot v2 report summary with previous-period data.
|
|
func (s *AnalyticsService) GetReportSummary(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint, businessHours bool) (*ReportSummaryResponse, error) {
|
|
current, err := s.reportSummaryCounts(ctx, accountID, since, until, reportType, id, businessHours)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
previousSince := since.Add(-(until.Sub(since)))
|
|
previous, err := s.reportSummaryCounts(ctx, accountID, previousSince, since, reportType, id, businessHours)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
current.Previous = previous
|
|
return current, nil
|
|
}
|
|
|
|
// RecordEvent records a new reporting event.
|
|
func (s *AnalyticsService) RecordEvent(ctx context.Context, event *model.ReportingEvent) error {
|
|
if err := s.eventRepo.Create(ctx, event); err != nil {
|
|
applogger.L().Errorf("RecordEvent: %v", err)
|
|
return err
|
|
}
|
|
if s.rollups != nil {
|
|
if err := s.rollups.RollupEvent(ctx, event); err != nil {
|
|
applogger.L().Errorf("RecordEvent rollup: %v", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RollupDaily aggregates events into daily rollups for a given account and date.
|
|
func (s *AnalyticsService) RollupDaily(ctx context.Context, accountID uint, date time.Time) error {
|
|
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
|
endOfDay := startOfDay.Add(24 * time.Hour)
|
|
|
|
// Aggregate events by each metric and dimension type
|
|
dimensions := []model.DimensionType{model.DimensionAccount, model.DimensionAgent, model.DimensionInbox, model.DimensionTeam}
|
|
metricNames := []string{
|
|
model.MetricNameFirstResponse,
|
|
model.MetricNameReplyTime,
|
|
model.MetricNameResolutionTime,
|
|
model.MetricNameBotResolutionsCount,
|
|
model.MetricNameBotHandoffsCount,
|
|
}
|
|
|
|
for _, dimType := range dimensions {
|
|
for _, metricName := range metricNames {
|
|
events, err := s.eventRepo.FindByMetric(ctx, accountID, metricName, startOfDay, endOfDay)
|
|
if err != nil {
|
|
applogger.L().Errorf("RollupDaily FindByMetric(%s, %s): %v", dimType, metricName, err)
|
|
continue
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Determine the rollup metric mapping
|
|
rollupMetric := mapEventNameToRollupMetric(metricName)
|
|
if rollupMetric == "" {
|
|
continue
|
|
}
|
|
|
|
// Group events by dimension_id based on dimension type
|
|
grouped := s.groupEventsByDimension(events, dimType)
|
|
|
|
for dimID, evts := range grouped {
|
|
var sumValue, sumBH float64
|
|
for _, e := range evts {
|
|
sumValue += e.Value
|
|
sumBH += e.ValueInBusinessHours
|
|
}
|
|
|
|
rollup := &model.ReportingEventsRollup{
|
|
AccountID: accountID,
|
|
Date: startOfDay,
|
|
DimensionType: dimType,
|
|
DimensionID: dimID,
|
|
Metric: rollupMetric,
|
|
Count: int64(len(evts)),
|
|
SumValue: sumValue,
|
|
SumValueBusinessHours: sumBH,
|
|
}
|
|
|
|
if err := s.rollupRepo.Create(ctx, rollup); err != nil {
|
|
applogger.L().Errorf("RollupDaily create rollup: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func mapEventNameToRollupMetric(name string) model.RollupMetric {
|
|
switch name {
|
|
case model.MetricNameFirstResponse:
|
|
return model.MetricFirstResponse
|
|
case model.MetricNameReplyTime:
|
|
return model.MetricReplyTime
|
|
case model.MetricNameResolutionTime:
|
|
return model.MetricResolutionTime
|
|
case model.MetricNameBotResolutionsCount:
|
|
return model.MetricBotResolutions
|
|
case model.MetricNameBotHandoffsCount:
|
|
return model.MetricBotHandoffs
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (s *AnalyticsService) groupEventsByDimension(events []model.ReportingEvent, dimType model.DimensionType) map[uint][]model.ReportingEvent {
|
|
grouped := map[uint][]model.ReportingEvent{}
|
|
for _, e := range events {
|
|
var dimID uint
|
|
switch dimType {
|
|
case model.DimensionAccount:
|
|
dimID = e.AccountID
|
|
case model.DimensionAgent:
|
|
if e.UserID != nil {
|
|
dimID = *e.UserID
|
|
} else {
|
|
dimID = 0
|
|
}
|
|
case model.DimensionInbox:
|
|
if e.InboxID != nil {
|
|
dimID = *e.InboxID
|
|
} else {
|
|
dimID = 0
|
|
}
|
|
case model.DimensionTeam:
|
|
dimID = e.AccountID // team is approximated as account for now
|
|
}
|
|
grouped[dimID] = append(grouped[dimID], e)
|
|
}
|
|
return grouped
|
|
}
|
|
|
|
// GetBotSummary returns bot-level summary metrics.
|
|
// Reference: Chatwoot reports#bot_summary
|
|
func (s *AnalyticsService) GetBotSummary(ctx context.Context, accountID uint, since, until time.Time, reportType string, id uint) (*BotSummaryResponse, error) {
|
|
current, err := s.botSummaryCounts(ctx, accountID, since, until, reportType, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
previousSince := since.Add(-(until.Sub(since)))
|
|
previous, err := s.botSummaryCounts(ctx, accountID, previousSince, since, reportType, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
current.Previous = previous
|
|
return current, nil
|
|
}
|
|
|
|
// BotSummaryResponse holds bot summary metrics.
|
|
type BotSummaryResponse struct {
|
|
BotResolutionsCount int64 `json:"bot_resolutions_count"`
|
|
BotHandoffsCount int64 `json:"bot_handoffs_count"`
|
|
Previous *BotSummaryResponse `json:"previous,omitempty"`
|
|
}
|
|
|
|
// GetConversationsByType returns conversation metrics filtered by report type.
|
|
// Reference: Chatwoot reports#conversations — type param is required
|
|
func (s *AnalyticsService) GetConversationsByType(ctx context.Context, accountID uint, reportType string, page int) (interface{}, error) {
|
|
return s.conversationMetricsByType(ctx, accountID, reportType, page)
|
|
}
|
|
|
|
// GetConversationsSummary returns conversations summary report.
|
|
// Reference: Chatwoot reports#conversations_summary
|
|
func (s *AnalyticsService) GetConversationsSummary(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
|
|
return s.conversationSummary(ctx, accountID, since, until)
|
|
}
|
|
|
|
// GetBotMetrics returns bot metrics.
|
|
// Reference: Chatwoot reports#bot_metrics — V2::Reports::BotMetricsBuilder
|
|
func (s *AnalyticsService) GetBotMetrics(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
|
|
return s.botMetrics(ctx, accountID, since, until)
|
|
}
|
|
|
|
// GetInboxLabelMatrix returns inbox-label matrix data.
|
|
// Reference: Chatwoot reports#inbox_label_matrix — V2::Reports::InboxLabelMatrixBuilder
|
|
func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint, filter InboxLabelMatrixFilter) (interface{}, error) {
|
|
return s.inboxLabelMatrix(ctx, accountID, filter)
|
|
}
|
|
|
|
// GetFirstResponseTimeDistribution returns first response time distribution.
|
|
// Reference: Chatwoot reports#first_response_time_distribution
|
|
func (s *AnalyticsService) GetFirstResponseTimeDistribution(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
|
|
return s.firstResponseTimeDistribution(ctx, accountID, since, until)
|
|
}
|
|
|
|
// GetOutgoingMessagesCount returns outgoing message count metrics.
|
|
// Reference: Chatwoot reports#outgoing_messages_count
|
|
func (s *AnalyticsService) GetOutgoingMessagesCount(ctx context.Context, accountID uint, since, until time.Time) (interface{}, error) {
|
|
return s.outgoingMessagesCount(ctx, accountID, since, until, "")
|
|
}
|
|
|
|
func (s *AnalyticsService) GetOutgoingMessagesCountGrouped(ctx context.Context, accountID uint, since, until time.Time, groupBy string) (interface{}, error) {
|
|
return s.outgoingMessagesCount(ctx, accountID, since, until, groupBy)
|
|
}
|