package service import ( "context" "fmt" "time" "github.com/gochat/gochat/internal/automation" applogger "github.com/gochat/gochat/pkg/logger" ) // CsatMetricsService provides dedicated CSAT metrics reporting with trend analysis // and CSV export capability, separate from the core CsatSurveyService. // Reference: Chatwoot CSAT Reports / csat_report — enterprise metrics + export type CsatMetricsService struct { db automation.DBProvider } // NewCsatMetricsService creates a new CsatMetricsService. func NewCsatMetricsService(db automation.DBProvider) *CsatMetricsService { return &CsatMetricsService{db: db} } // --- Response DTOs --- // CsatMetricsReport holds the aggregated CSAT metrics report. // Reference: Chatwoot GET csat_report — total responses, average rating, response rate, trend over time type CsatMetricsReport struct { TotalResponses int `json:"total_responses"` TotalSentMessagesCount int64 `json:"total_sent_messages_count"` // Chatwoot: count of messages with csat_survey content_type AverageRating float64 `json:"average_rating"` ResponseRate float64 `json:"response_rate"` Trend []TrendPoint `json:"trend"` } // TrendPoint represents a single day's CSAT metrics in a time-series trend. type TrendPoint struct { Date string `json:"date"` AverageRating float64 `json:"average_rating"` ResponseCount int `json:"response_count"` } // CsatMetricRow represents a single CSAT response row for CSV export. type CsatMetricRow struct { ConversationID string `csv:"conversation_id"` ContactID string `csv:"contact_id"` AssignedAgentID string `csv:"assigned_agent_id"` Rating string `csv:"rating"` FeedbackMessage string `csv:"feedback_message"` CreatedAt string `csv:"created_at"` } // GetMetrics computes aggregated CSAT metrics for an account within a date range. // It returns total responses, average rating, response rate (responses vs. resolved conversations), // and a day-by-day trend of average ratings. func (s *CsatMetricsService) GetMetrics(ctx context.Context, accountID uint, since, until *time.Time) (*CsatMetricsReport, error) { query := s.db.DB().WithContext(ctx). Where("account_id = ? AND deleted_at IS NULL", accountID) if since != nil { query = query.Where("created_at >= ?", *since) } if until != nil { query = query.Where("created_at <= ?", *until) } var responses []automation.CsatSurveyResponse if err := query.Find(&responses).Error; err != nil { applogger.L().Errorf("CsatMetricsService.GetMetrics: failed to query responses: %v", err) return nil, fmt.Errorf("failed to query csat responses: %w", err) } report := &CsatMetricsReport{ TotalResponses: len(responses), } // Compute total_sent_messages_count: count of messages with content_type = "csat_survey" // that were sent (outgoing) within the date range for this account. // Reference: Chatwoot csat_report total_sent_messages_count var totalSent int64 sentQuery := s.db.DB().WithContext(ctx). Table("messages"). Where("account_id = ? AND content_type = ? AND message_type = ?", accountID, "csat_survey", "outgoing") if since != nil { sentQuery = sentQuery.Where("created_at >= ?", *since) } if until != nil { sentQuery = sentQuery.Where("created_at <= ?", *until) } sentQuery.Count(&totalSent) report.TotalSentMessagesCount = totalSent // Compute average rating totalRating := 0 for _, r := range responses { if r.Rating >= 1 && r.Rating <= 5 { totalRating += r.Rating } } if report.TotalResponses > 0 { report.AverageRating = float64(totalRating) / float64(report.TotalResponses) } // Compute response rate: CSAT responses / total resolved conversations in period. // We count distinct conversation_ids with responses as numerator and // total resolved conversations as denominator. var totalResolved int64 resolvedQuery := s.db.DB().WithContext(ctx). Table("conversations"). Where("account_id = ? AND status = ?", accountID, "resolved") if since != nil { resolvedQuery = resolvedQuery.Where("updated_at >= ?", *since) } if until != nil { resolvedQuery = resolvedQuery.Where("updated_at <= ?", *until) } if err := resolvedQuery.Count(&totalResolved).Error; err != nil { applogger.L().Errorf("CsatMetricsService.GetMetrics: failed to count resolved conversations: %v", err) // Non-critical: set rate to 0 and continue totalResolved = 0 } if totalResolved > 0 { report.ResponseRate = float64(report.TotalResponses) / float64(totalResolved) * 100 } else if report.TotalResponses > 0 { // No resolved conversations found but responses exist — show 100% as fallback report.ResponseRate = 100 } // Build day-by-day trend trendMap := map[string]*trendAccumulator{} for _, r := range responses { dayKey := r.CreatedAt.Format("2006-01-02") acc, exists := trendMap[dayKey] if !exists { acc = &trendAccumulator{} trendMap[dayKey] = acc } acc.totalRating += r.Rating acc.count++ } // Sort trend by date var sortedDays []string for d := range trendMap { sortedDays = append(sortedDays, d) } // Simple sort — small dataset for i := 0; i < len(sortedDays); i++ { for j := i + 1; j < len(sortedDays); j++ { if sortedDays[i] > sortedDays[j] { sortedDays[i], sortedDays[j] = sortedDays[j], sortedDays[i] } } } report.Trend = make([]TrendPoint, 0, len(sortedDays)) for _, day := range sortedDays { acc := trendMap[day] var avg float64 if acc.count > 0 { avg = float64(acc.totalRating) / float64(acc.count) } report.Trend = append(report.Trend, TrendPoint{ Date: day, AverageRating: avg, ResponseCount: acc.count, }) } return report, nil } // ExportCSV retrieves all CSAT response rows for an account within a date range, // formatted as CsatMetricRow values suitable for CSV generation. func (s *CsatMetricsService) ExportCSV(ctx context.Context, accountID uint, since, until *time.Time) ([]CsatMetricRow, error) { query := s.db.DB().WithContext(ctx). Where("account_id = ? AND deleted_at IS NULL", accountID) if since != nil { query = query.Where("created_at >= ?", *since) } if until != nil { query = query.Where("created_at <= ?", *until) } var responses []automation.CsatSurveyResponse if err := query.Order("created_at ASC").Find(&responses).Error; err != nil { applogger.L().Errorf("CsatMetricsService.ExportCSV: failed to query responses: %v", err) return nil, fmt.Errorf("failed to query csat responses for export: %w", err) } rows := make([]CsatMetricRow, 0, len(responses)) for _, r := range responses { row := CsatMetricRow{ ConversationID: fmt.Sprintf("%d", r.ConversationID), ContactID: fmt.Sprintf("%d", r.ContactID), Rating: fmt.Sprintf("%d", r.Rating), FeedbackMessage: r.FeedbackMessage, CreatedAt: r.CreatedAt.Format(time.RFC3339), } if r.AssignedAgentID != nil { row.AssignedAgentID = fmt.Sprintf("%d", *r.AssignedAgentID) } rows = append(rows, row) } return rows, nil } // trendAccumulator is an internal helper for computing daily averages. type trendAccumulator struct { totalRating int count int }