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.
423 lines
14 KiB
Go
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
|
|
}
|
|
}
|