215 lines
7.1 KiB
Go
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
|
|
} |