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

423 lines
14 KiB
Go

package service
import (
"context"
"fmt"
"strconv"
"time"
"github.com/gochat/gochat/internal/model"
"gorm.io/gorm"
)
type reportCSVMetricSet struct {
ConversationsCount int64
ResolvedCount int64
AvgResolution float64
AvgFirstResponse float64
AvgReply float64
}
func (s *AnalyticsService) GetAgentReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "agent", businessHours)
if err != nil {
return nil, err
}
var users []model.User
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&users).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(users))
for _, user := range users {
metric := metrics[user.ID]
rows = append(rows, append([]string{user.Name}, readableReportMetrics(metric)...))
}
return rows, nil
}
func (s *AnalyticsService) GetInboxReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "inbox", businessHours)
if err != nil {
return nil, err
}
var inboxes []model.Inbox
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&inboxes).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(inboxes))
for _, inbox := range inboxes {
metric := metrics[inbox.ID]
rows = append(rows, []string{
inbox.Name,
inbox.ChannelType,
strconv.FormatInt(metric.ConversationsCount, 10),
formatReportDuration(metric.AvgFirstResponse),
formatReportDuration(metric.AvgResolution),
})
}
return rows, nil
}
func (s *AnalyticsService) GetTeamReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
metrics, err := s.reportMetricsByDimension(ctx, accountID, since, until, "team", businessHours)
if err != nil {
return nil, err
}
var teams []model.Team
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&teams).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(teams))
for _, team := range teams {
metric := metrics[team.ID]
rows = append(rows, append([]string{team.Name}, readableReportMetrics(metric)...))
}
return rows, nil
}
func (s *AnalyticsService) GetLabelReportCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
metrics, err := s.reportMetricsByLabel(ctx, accountID, since, until, businessHours)
if err != nil {
return nil, err
}
var labels []model.Tag
if err := db.WithContext(ctx).Where("account_id = ?", accountID).Order("id ASC").Find(&labels).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(labels))
for _, label := range labels {
metric := metrics[label.ID]
rows = append(rows, []string{
label.Name,
strconv.FormatInt(metric.ConversationsCount, 10),
formatReportDuration(metric.AvgFirstResponse),
formatReportDuration(metric.AvgResolution),
formatReportDuration(metric.AvgReply),
strconv.FormatInt(metric.ResolvedCount, 10),
})
}
return rows, nil
}
func (s *AnalyticsService) GetConversationsSummaryCSVRows(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) ([][]string, error) {
summary, err := s.conversationSummary(ctx, accountID, since, until)
if err != nil {
return nil, err
}
return [][]string{{
intString(summary["conversations_count"]),
intString(summary["incoming_messages_count"]),
intString(summary["outgoing_messages_count"]),
formatReportDuration(floatValue(summary["avg_first_response_time"])),
formatReportDuration(floatValue(summary["avg_resolution_time"])),
intString(summary["resolutions_count"]),
formatReportDuration(floatValue(summary["reply_time"])),
}}, nil
}
func (s *AnalyticsService) GetConversationTrafficCSVRows(ctx context.Context, accountID uint, since, until time.Time, timezoneOffset float64) ([][]string, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
loc := time.FixedZone("report", int(timezoneOffset*3600))
startLocal := time.Date(since.In(loc).Year(), since.In(loc).Month(), since.In(loc).Day(), 0, 0, 0, 0, loc)
endLocal := time.Date(until.In(loc).Year(), until.In(loc).Month(), until.In(loc).Day(), 0, 0, 0, 0, loc)
dates := make([]string, 0)
for day := startLocal; day.Before(endLocal); day = day.AddDate(0, 0, 1) {
dates = append(dates, day.Format("2006-01-02"))
}
counts := map[string]map[int]int64{}
var conversations []model.Conversation
if err := db.WithContext(ctx).Where("account_id = ? AND created_at >= ? AND created_at < ?", accountID, since, until).Find(&conversations).Error; err != nil {
return nil, err
}
for _, conversation := range conversations {
localTime := conversation.CreatedAt.In(loc)
dateKey := localTime.Format("2006-01-02")
if counts[dateKey] == nil {
counts[dateKey] = map[int]int64{}
}
counts[dateKey][localTime.Hour()]++
}
rows := make([][]string, 0, 25)
rows = append(rows, append([]string{"Start of the hour"}, dates...))
for hour := 0; hour < 24; hour++ {
row := []string{fmt.Sprintf("%02d:00", hour)}
for _, date := range dates {
row = append(row, strconv.FormatInt(counts[date][hour], 10))
}
rows = append(rows, row)
}
return rows, nil
}
func (s *AnalyticsService) reportMetricsByDimension(ctx context.Context, accountID uint, since, until time.Time, dimension string, businessHours bool) (map[uint]reportCSVMetricSet, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
result := map[uint]reportCSVMetricSet{}
field := map[string]string{"agent": "assignee_id", "inbox": "inbox_id", "team": "team_id"}[dimension]
if field == "" {
return result, nil
}
if err := loadConversationCounts(ctx, db, result, accountID, since, until, field, false); err != nil {
return nil, err
}
if err := loadConversationCounts(ctx, db, result, accountID, since, until, field, true); err != nil {
return nil, err
}
if err := s.loadDimensionEventAverages(ctx, db, result, accountID, since, until, dimension, businessHours); err != nil {
return nil, err
}
return result, nil
}
func loadConversationCounts(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, field string, resolved bool) error {
var rows []struct {
ID uint
Count int64
}
query := db.WithContext(ctx).Model(&model.Conversation{}).
Select(field+" AS id, COUNT(*) AS count").
Where("account_id = ? AND "+field+" IS NOT NULL", accountID).
Group(field)
if resolved {
query = query.Where("resolved_at IS NOT NULL AND resolved_at >= ? AND resolved_at < ?", since, until)
} else {
query = query.Where("created_at >= ? AND created_at < ?", since, until)
}
if err := query.Scan(&rows).Error; err != nil {
return err
}
for _, row := range rows {
metric := result[row.ID]
if resolved {
metric.ResolvedCount = row.Count
} else {
metric.ConversationsCount = row.Count
}
result[row.ID] = metric
}
return nil
}
func (s *AnalyticsService) loadDimensionEventAverages(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, dimension string, businessHours bool) error {
valueColumn := "reporting_events.value"
if businessHours {
valueColumn = "reporting_events.value_in_business_hours"
}
field := map[string]string{"agent": "reporting_events.user_id", "inbox": "reporting_events.inbox_id", "team": "conversations.team_id"}[dimension]
queryBase := func(names []string) *gorm.DB {
q := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Select(field+" AS id, AVG("+valueColumn+") AS value").
Where("reporting_events.account_id = ? AND reporting_events.name IN ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", accountID, names, since, until).
Where(field + " IS NOT NULL").
Group(field)
if dimension == "team" {
q = q.Joins("INNER JOIN conversations ON conversations.id = reporting_events.conversation_id")
}
return q
}
load := func(names []string, apply func(*reportCSVMetricSet, float64)) error {
var rows []struct {
ID uint
Value float64
}
if err := queryBase(names).Scan(&rows).Error; err != nil {
return err
}
for _, row := range rows {
metric := result[row.ID]
apply(&metric, row.Value)
result[row.ID] = metric
}
return nil
}
if err := load([]string{model.MetricNameFirstResponse}, func(metric *reportCSVMetricSet, value float64) { metric.AvgFirstResponse = value }); err != nil {
return err
}
if err := load([]string{"conversation_resolved", model.MetricNameResolutionTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgResolution = value }); err != nil {
return err
}
return load([]string{model.MetricNameReplyTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgReply = value })
}
func (s *AnalyticsService) reportMetricsByLabel(ctx context.Context, accountID uint, since, until time.Time, businessHours bool) (map[uint]reportCSVMetricSet, error) {
db, err := s.analyticsDB()
if err != nil {
return nil, err
}
result := map[uint]reportCSVMetricSet{}
if err := loadLabelConversationCounts(ctx, db, result, accountID, since, until, false); err != nil {
return nil, err
}
if err := loadLabelConversationCounts(ctx, db, result, accountID, since, until, true); err != nil {
return nil, err
}
if err := loadLabelEventAverages(ctx, db, result, accountID, since, until, businessHours); err != nil {
return nil, err
}
return result, nil
}
func loadLabelConversationCounts(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, resolved bool) error {
var rows []struct {
ID uint
Count int64
}
query := db.WithContext(ctx).Table("conversation_labels").
Select("conversation_labels.tag_id AS id, COUNT(*) AS count").
Joins("INNER JOIN conversations ON conversations.id = conversation_labels.conversation_id").
Where("conversation_labels.account_id = ?", accountID).
Group("conversation_labels.tag_id")
if resolved {
query = query.Where("conversations.resolved_at IS NOT NULL AND conversations.resolved_at >= ? AND conversations.resolved_at < ?", since, until)
} else {
query = query.Where("conversations.created_at >= ? AND conversations.created_at < ?", since, until)
}
if err := query.Scan(&rows).Error; err != nil {
return err
}
for _, row := range rows {
metric := result[row.ID]
if resolved {
metric.ResolvedCount = row.Count
} else {
metric.ConversationsCount = row.Count
}
result[row.ID] = metric
}
return nil
}
func loadLabelEventAverages(ctx context.Context, db *gorm.DB, result map[uint]reportCSVMetricSet, accountID uint, since, until time.Time, businessHours bool) error {
valueColumn := "reporting_events.value"
if businessHours {
valueColumn = "reporting_events.value_in_business_hours"
}
load := func(names []string, apply func(*reportCSVMetricSet, float64)) error {
var rows []struct {
ID uint
Value float64
}
err := db.WithContext(ctx).Model(&model.ReportingEvent{}).
Select("conversation_labels.tag_id AS id, AVG("+valueColumn+") AS value").
Joins("INNER JOIN conversation_labels ON conversation_labels.conversation_id = reporting_events.conversation_id").
Where("reporting_events.account_id = ? AND reporting_events.name IN ? AND reporting_events.created_at >= ? AND reporting_events.created_at < ?", accountID, names, since, until).
Group("conversation_labels.tag_id").Scan(&rows).Error
if err != nil {
return err
}
for _, row := range rows {
metric := result[row.ID]
apply(&metric, row.Value)
result[row.ID] = metric
}
return nil
}
if err := load([]string{model.MetricNameFirstResponse}, func(metric *reportCSVMetricSet, value float64) { metric.AvgFirstResponse = value }); err != nil {
return err
}
if err := load([]string{"conversation_resolved", model.MetricNameResolutionTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgResolution = value }); err != nil {
return err
}
return load([]string{model.MetricNameReplyTime}, func(metric *reportCSVMetricSet, value float64) { metric.AvgReply = value })
}
func readableReportMetrics(metric reportCSVMetricSet) []string {
return []string{
strconv.FormatInt(metric.ConversationsCount, 10),
formatReportDuration(metric.AvgFirstResponse),
formatReportDuration(metric.AvgResolution),
formatReportDuration(metric.AvgReply),
strconv.FormatInt(metric.ResolvedCount, 10),
}
}
func formatReportDuration(seconds float64) string {
value := int64(seconds)
if value <= 0 {
return "N/A"
}
units := []struct {
Name string
Seconds int64
}{{"day", 86400}, {"hour", 3600}, {"minute", 60}, {"second", 1}}
parts := make([]string, 0, 2)
remaining := value
for _, unit := range units {
count := remaining / unit.Seconds
remaining %= unit.Seconds
if count == 0 {
continue
}
label := unit.Name
if count != 1 {
label += "s"
}
parts = append(parts, strconv.FormatInt(count, 10)+" "+label)
if len(parts) == 2 {
break
}
}
if len(parts) == 0 {
return "0 seconds"
}
return parts[0] + optionalSecondPart(parts)
}
func optionalSecondPart(parts []string) string {
if len(parts) < 2 {
return ""
}
return " " + parts[1]
}
func intString(value interface{}) string {
switch typed := value.(type) {
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
case float64:
return strconv.FormatInt(int64(typed), 10)
default:
return fmt.Sprintf("%v", value)
}
}
func floatValue(value interface{}) float64 {
switch typed := value.(type) {
case float64:
return typed
case int64:
return float64(typed)
case int:
return float64(typed)
default:
return 0
}
}