397 lines
14 KiB
Go
397 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/gochat/gochat/internal/model"
|
|
"github.com/gochat/gochat/internal/repository"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
func NewAnalyticsService(
|
|
eventRepo *repository.ReportingEventRepo,
|
|
rollupRepo *repository.ReportingEventsRollupRepo,
|
|
) *AnalyticsService {
|
|
return &AnalyticsService{
|
|
eventRepo: eventRepo,
|
|
rollupRepo: rollupRepo,
|
|
}
|
|
}
|
|
|
|
// --- 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// 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) {
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
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) {
|
|
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) {
|
|
// TODO: implement real-time open/unattended/unassigned/pending counts from conversations table
|
|
// Currently returns placeholder data until conversation queries are implemented
|
|
return &ConversationMetrics{
|
|
OpenCount: 0,
|
|
UnattendedCount: 0,
|
|
UnassignedCount: 0,
|
|
PendingCount: 0,
|
|
}, nil
|
|
}
|
|
|
|
// 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) ([]GroupedConversationMetric, error) {
|
|
// TODO: implement grouped conversation metrics from conversations table
|
|
// Currently returns empty until group-by queries are implemented
|
|
return []GroupedConversationMetric{}, 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
|
|
}
|
|
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) (*BotSummaryResponse, error) {
|
|
// TODO: implement bot summary using V2::Reports::BotSummaryBuilder logic
|
|
return &BotSummaryResponse{}, nil
|
|
}
|
|
|
|
// BotSummaryResponse holds bot summary metrics.
|
|
type BotSummaryResponse struct {
|
|
BotResolutions int64 `json:"bot_resolutions"`
|
|
BotHandoffs int64 `json:"bot_handoffs"`
|
|
SelfService int64 `json:"self_service"`
|
|
HumanService int64 `json:"human_service"`
|
|
}
|
|
|
|
// 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, since, until time.Time) (interface{}, error) {
|
|
// TODO: implement conversation metrics by type using V2::Reports::Conversations::ConversationMetricsService
|
|
return gin.H{}, nil
|
|
}
|
|
|
|
// 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) {
|
|
// TODO: implement conversations summary using V2::Reports::Conversations::SummaryBuilder
|
|
return gin.H{}, nil
|
|
}
|
|
|
|
// 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) {
|
|
// TODO: implement bot metrics
|
|
return gin.H{}, nil
|
|
}
|
|
|
|
// GetInboxLabelMatrix returns inbox-label matrix data.
|
|
// Reference: Chatwoot reports#inbox_label_matrix — V2::Reports::InboxLabelMatrixBuilder
|
|
func (s *AnalyticsService) GetInboxLabelMatrix(ctx context.Context, accountID uint) (interface{}, error) {
|
|
// TODO: implement inbox label matrix
|
|
return gin.H{}, nil
|
|
}
|
|
|
|
// 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) {
|
|
// TODO: implement FRT distribution
|
|
return gin.H{}, nil
|
|
}
|
|
|
|
// 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) {
|
|
// TODO: implement outgoing messages count
|
|
return gin.H{}, nil
|
|
}
|