Files
gochat/backend/internal/service/analytics_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

482 lines
17 KiB
Go

package service
import (
"context"
"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
}
// 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
}
// 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)
}