Files
gochat/internal/service/csat_metrics_service.go
T
2026-06-04 15:44:48 +08:00

215 lines
7.1 KiB
Go

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
}