Files
gochat/backend/internal/handler/api/v1/analytics_handler.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

630 lines
19 KiB
Go

package v1
import (
"encoding/csv"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/service"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/gochat/gochat/pkg/response"
)
// AnalyticsHandler handles reporting/analytics API endpoints.
// Reference: Chatwoot app/controllers/api/v1/reports_controller.rb
type AnalyticsHandler struct {
svc *service.AnalyticsService
}
func NewAnalyticsHandler(svc *service.AnalyticsService) *AnalyticsHandler {
return &AnalyticsHandler{svc: svc}
}
// Index returns metric time-series for the overview charts.
// GET /api/v2/accounts/:account_id/reports?metric=...&since=...&until=...
func (h *AnalyticsHandler) Index(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
metric := c.Query("metric")
if metric == "" {
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "metric parameter is required")
return
}
id := uint(0)
if rawID := c.Query("id"); rawID != "" {
parsed, err := strconv.ParseUint(rawID, 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return
}
id = uint(parsed)
}
businessHours := c.Query("business_hours") == "true" || c.Query("business_hours") == "1"
timezoneOffset, ok := parseReportTimezoneOffset(c)
if !ok {
return
}
result, err := h.svc.GetTimeseries(c.Request.Context(), accountID, metric, since, until, c.DefaultQuery("type", "account"), id, c.Query("group_by"), timezoneOffset, businessHours)
if err != nil {
applogger.L().Errorf("Timeseries report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate report")
return
}
c.JSON(http.StatusOK, result)
}
// parseAccountID extracts account_id from URL params.
func parseAccountID(c *gin.Context) (uint, bool) {
id := getAccountID(c)
if id == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account_id")
return 0, false
}
return id, true
}
// parseDateRange extracts since/unsince from query params.
func parseDateRange(c *gin.Context) (since, until time.Time, ok bool) {
sinceStr := c.Query("since")
untilStr := c.Query("until")
if sinceStr == "" || untilStr == "" {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "since and until query params are required")
return time.Time{}, time.Time{}, false
}
since, err := parseChatwootReportTime(sinceStr)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid since date format")
return time.Time{}, time.Time{}, false
}
until, err = parseChatwootReportTime(untilStr)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid until date format")
return time.Time{}, time.Time{}, false
}
return since, until, true
}
func parseChatwootReportTime(value string) (time.Time, error) {
if unixSeconds, err := strconv.ParseInt(value, 10, 64); err == nil {
return time.Unix(unixSeconds, 0).UTC(), nil
}
return time.Parse(time.RFC3339, value)
}
func parseReportBusinessHours(c *gin.Context) bool {
return c.Query("business_hours") == "true" || c.Query("business_hours") == "1"
}
func parseReportTimezoneOffset(c *gin.Context) (float64, bool) {
if raw := c.Query("timezone_offset"); raw != "" {
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid timezone_offset")
return 0, false
}
return parsed, true
}
return 0, true
}
func parseOptionalReportID(c *gin.Context) (uint, bool) {
if rawID := c.Query("id"); rawID != "" {
parsed, err := strconv.ParseUint(rawID, 10, 64)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid id")
return 0, false
}
return uint(parsed), true
}
return 0, true
}
func parseConversationTrafficRange(c *gin.Context) (time.Time, time.Time, float64, bool) {
timezoneOffset, ok := parseReportTimezoneOffset(c)
if !ok {
return time.Time{}, time.Time{}, 0, false
}
if c.Query("since") != "" || c.Query("until") != "" {
since, until, ok := parseDateRange(c)
return since, until, timezoneOffset, ok
}
daysBefore := 6
if raw := c.Query("days_before"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid days_before")
return time.Time{}, time.Time{}, 0, false
}
daysBefore = parsed
}
loc := fixedOffsetLocation(timezoneOffset)
now := time.Now().In(loc)
untilLocal := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
sinceLocal := untilLocal.AddDate(0, 0, -daysBefore)
return sinceLocal.UTC(), untilLocal.UTC(), timezoneOffset, true
}
func fixedOffsetLocation(offsetHours float64) *time.Location {
seconds := int(offsetHours * 3600)
return time.FixedZone(reportTimezoneName(offsetHours), seconds)
}
func reportTimezoneName(offsetHours float64) string {
if offsetHours == 0 {
return "UTC"
}
sign := "+"
if offsetHours < 0 {
sign = "-"
offsetHours = -offsetHours
}
totalMinutes := int(offsetHours*60 + 0.5)
return "UTC" + sign + twoDigit(totalMinutes/60) + ":" + twoDigit(totalMinutes%60)
}
func twoDigit(value int) string {
if value < 10 {
return "0" + strconv.Itoa(value)
}
return strconv.Itoa(value)
}
func writeReportCSV(c *gin.Context, filename string, since, until time.Time, headers []string, rows [][]string) {
c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment; filename="+filename+".csv")
writer := csv.NewWriter(c.Writer)
_ = writer.Write([]string{"Reporting period " + since.Format("2006-01-02") + " to " + until.Format("2006-01-02")})
_ = writer.Write([]string{})
_ = writer.Write(headers)
for _, row := range rows {
_ = writer.Write(row)
}
writer.Flush()
}
func writeConversationTrafficCSV(c *gin.Context, timezoneOffset float64, rows [][]string) {
c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment; filename=conversation_traffic_reports.csv")
writer := csv.NewWriter(c.Writer)
_ = writer.Write([]string{"Timezone", reportTimezoneName(timezoneOffset)})
_ = writer.Write([]string{})
for _, row := range rows {
_ = writer.Write(row)
}
writer.Flush()
}
// Summary returns account-level aggregated metrics.
// GET /api/v1/accounts/:account_id/reports/summary
func (h *AnalyticsHandler) Summary(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
if _, ok := parseReportTimezoneOffset(c); !ok {
return
}
id, ok := parseOptionalReportID(c)
if !ok {
return
}
result, err := h.svc.GetReportSummary(c.Request.Context(), accountID, since, until, c.DefaultQuery("type", "account"), id, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Summary report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate summary report")
return
}
c.JSON(http.StatusOK, result)
}
// AgentMetrics returns metrics grouped by agent.
// GET /api/v1/accounts/:account_id/reports/agents
func (h *AnalyticsHandler) AgentMetrics(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
result, err := h.svc.GetAgentReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Agent metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate agent metrics")
return
}
writeReportCSV(c, "agents_report", since, until, []string{
"Agent name",
"Assigned conversations",
"Avg first response time",
"Avg resolution time",
"Avg customer waiting time",
"Resolution Count",
}, result)
}
// InboxMetrics returns metrics grouped by inbox.
// GET /api/v1/accounts/:account_id/reports/inboxes
func (h *AnalyticsHandler) InboxMetrics(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
result, err := h.svc.GetInboxReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Inbox metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate inbox metrics")
return
}
writeReportCSV(c, "inboxes_report", since, until, []string{
"Inbox name",
"Inbox type",
"No. of conversations",
"Avg first response time",
"Avg resolution time",
}, result)
}
// LabelMetrics returns metrics grouped by label.
// GET /api/v1/accounts/:account_id/reports/labels
func (h *AnalyticsHandler) LabelMetrics(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
result, err := h.svc.GetLabelReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Label metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate label metrics")
return
}
writeReportCSV(c, "labels_report", since, until, []string{
"Label",
"No. of conversations",
"Avg first response time",
"Avg resolution time",
"Avg reply time",
"Resolution Count",
}, result)
}
// TeamMetrics returns metrics grouped by team.
// GET /api/v1/accounts/:account_id/reports/teams
func (h *AnalyticsHandler) TeamMetrics(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
result, err := h.svc.GetTeamReportCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Team metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate team metrics")
return
}
writeReportCSV(c, "teams_report", since, until, []string{
"Team name",
"Conversations count",
"Avg first response time",
"Avg resolution time",
"Avg customer waiting time",
"Resolution Count",
}, result)
}
// ConversationTraffic returns daily conversation traffic time-series.
// GET /api/v1/accounts/:account_id/reports/conversation_traffic
func (h *AnalyticsHandler) ConversationTraffic(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, timezoneOffset, ok := parseConversationTrafficRange(c)
if !ok {
return
}
result, err := h.svc.GetConversationTrafficCSVRows(c.Request.Context(), accountID, since, until, timezoneOffset)
if err != nil {
applogger.L().Errorf("Conversation traffic report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversation traffic")
return
}
writeConversationTrafficCSV(c, timezoneOffset, result)
}
// BotSummary returns bot-level summary metrics.
// GET /api/v1/accounts/:account_id/reports/bot_summary
// Reference: Chatwoot reports#bot_summary
func (h *AnalyticsHandler) BotSummary(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
id, ok := parseOptionalReportID(c)
if !ok {
return
}
result, err := h.svc.GetBotSummary(c.Request.Context(), accountID, since, until, c.DefaultQuery("type", "account"), id)
if err != nil {
applogger.L().Errorf("Bot summary report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate bot summary")
return
}
c.JSON(http.StatusOK, result)
}
// Conversations returns conversation metrics filtered by type.
// GET /api/v1/accounts/:account_id/reports/conversations
// Reference: Chatwoot reports#conversations — requires params[:type], returns 422 if missing
func (h *AnalyticsHandler) Conversations(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
reportType := c.Query("type")
if reportType == "" {
// Reference: Chatwoot returns head :unprocessable_entity if type is blank
c.Status(http.StatusUnprocessableEntity)
return
}
page := 1
if rawPage := c.Query("page"); rawPage != "" {
if parsed, err := strconv.Atoi(rawPage); err == nil && parsed > 0 {
page = parsed
}
}
result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, page)
if err != nil {
applogger.L().Errorf("Conversations report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversations report")
return
}
c.JSON(http.StatusOK, result)
}
// ConversationsSummary returns conversation summary report.
// GET /api/v1/accounts/:account_id/reports/conversations_summary
// Reference: Chatwoot reports#conversations_summary
func (h *AnalyticsHandler) ConversationsSummary(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
result, err := h.svc.GetConversationsSummaryCSVRows(c.Request.Context(), accountID, since, until, parseReportBusinessHours(c))
if err != nil {
applogger.L().Errorf("Conversations summary report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate conversations summary")
return
}
writeReportCSV(c, "conversations_summary_report", since, until, []string{
"Conversations",
"Messages received",
"Messages sent",
"Avg first response time",
"Avg resolution time",
"Resolution count",
"Avg customer waiting time",
}, result)
}
// BotMetrics returns bot metrics.
// GET /api/v1/accounts/:account_id/reports/bot_metrics
// Reference: Chatwoot reports#bot_metrics
func (h *AnalyticsHandler) BotMetrics(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
result, err := h.svc.GetBotMetrics(c.Request.Context(), accountID, since, until)
if err != nil {
applogger.L().Errorf("Bot metrics report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate bot metrics")
return
}
c.JSON(http.StatusOK, result)
}
// InboxLabelMatrix returns inbox-label matrix data.
// GET /api/v1/accounts/:account_id/reports/inbox_label_matrix
// Reference: Chatwoot reports#inbox_label_matrix
func (h *AnalyticsHandler) InboxLabelMatrix(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
filter, ok := parseInboxLabelMatrixFilter(c)
if !ok {
return
}
result, err := h.svc.GetInboxLabelMatrix(c.Request.Context(), accountID, filter)
if err != nil {
applogger.L().Errorf("Inbox label matrix report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate inbox label matrix")
return
}
c.JSON(http.StatusOK, result)
}
func parseInboxLabelMatrixFilter(c *gin.Context) (service.InboxLabelMatrixFilter, bool) {
filter := service.InboxLabelMatrixFilter{
InboxIDs: parseReportUintList(c, "inbox_ids"),
LabelIDs: parseReportUintList(c, "label_ids"),
}
sinceRaw := c.Query("since")
untilRaw := c.Query("until")
if sinceRaw == "" || untilRaw == "" {
return filter, true
}
since, err := parseChatwootReportTime(sinceRaw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid since date format")
return service.InboxLabelMatrixFilter{}, false
}
until, err := parseChatwootReportTime(untilRaw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid until date format")
return service.InboxLabelMatrixFilter{}, false
}
filter.Since = since
filter.Until = until
return filter, true
}
func parseReportUintList(c *gin.Context, key string) []uint {
values := append([]string{}, c.QueryArray(key)...)
values = append(values, c.QueryArray(key+"[]")...)
ids := make([]uint, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
parsed, err := strconv.ParseUint(part, 10, 64)
if err == nil && parsed > 0 {
ids = append(ids, uint(parsed))
}
}
}
return ids
}
// FirstResponseTimeDistribution returns first response time distribution.
// GET /api/v1/accounts/:account_id/reports/first_response_time_distribution
// Reference: Chatwoot reports#first_response_time_distribution
func (h *AnalyticsHandler) FirstResponseTimeDistribution(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseOptionalReportDateRange(c)
if !ok {
return
}
result, err := h.svc.GetFirstResponseTimeDistribution(c.Request.Context(), accountID, since, until)
if err != nil {
applogger.L().Errorf("First response time distribution report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate first response time distribution")
return
}
c.JSON(http.StatusOK, result)
}
func parseOptionalReportDateRange(c *gin.Context) (since, until time.Time, ok bool) {
sinceRaw := c.Query("since")
untilRaw := c.Query("until")
if sinceRaw == "" || untilRaw == "" {
return time.Time{}, time.Time{}, true
}
since, err := parseChatwootReportTime(sinceRaw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid since date format")
return time.Time{}, time.Time{}, false
}
until, err = parseChatwootReportTime(untilRaw)
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid until date format")
return time.Time{}, time.Time{}, false
}
return since, until, true
}
// OutgoingMessagesCount returns outgoing message count metrics.
// GET /api/v1/accounts/:account_id/reports/outgoing_messages_count
// Reference: Chatwoot reports#outgoing_messages_count
func (h *AnalyticsHandler) OutgoingMessagesCount(c *gin.Context) {
accountID, ok := parseAccountID(c)
if !ok {
return
}
since, until, ok := parseDateRange(c)
if !ok {
return
}
groupBy := c.Query("group_by")
if groupBy != "agent" && groupBy != "team" && groupBy != "inbox" && groupBy != "label" {
c.Status(http.StatusUnprocessableEntity)
return
}
result, err := h.svc.GetOutgoingMessagesCountGrouped(c.Request.Context(), accountID, since, until, groupBy)
if err != nil {
applogger.L().Errorf("Outgoing messages count report: %v", err)
response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate outgoing messages count")
return
}
c.JSON(http.StatusOK, result)
}