Files
gochat/backend/internal/service/csat_metrics_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

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
}