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) } // Drilldown lists the raw records behind one report chart bucket. // GET /api/v2/accounts/:account_id/reports/drilldown func (h *AnalyticsHandler) Drilldown(c *gin.Context) { accountID, ok := parseAccountID(c) if !ok { return } metric := c.Query("metric") bucketRaw := c.Query("bucket_timestamp") if metric == "" || bucketRaw == "" || c.Query("since") == "" || c.Query("until") == "" { c.Status(http.StatusUnprocessableEntity) return } since, until, ok := parseDateRange(c) if !ok { return } bucket, err := parseChatwootReportTime(bucketRaw) if err != nil { c.Status(http.StatusUnprocessableEntity) return } dimensionType := c.DefaultQuery("type", "account") if dimensionType != "account" && dimensionType != "inbox" && dimensionType != "agent" && dimensionType != "label" && dimensionType != "team" { c.Status(http.StatusUnprocessableEntity) return } id, ok := parseOptionalReportID(c) if !ok { return } if dimensionType != "account" && id == 0 { c.Status(http.StatusUnprocessableEntity) return } groupBy := c.DefaultQuery("group_by", "day") if groupBy != "hour" && groupBy != "day" && groupBy != "week" && groupBy != "month" && groupBy != "year" { c.Status(http.StatusUnprocessableEntity) return } offset, ok := parseReportTimezoneOffset(c) if !ok { return } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "25")) result, err := h.svc.GetDrilldown(c.Request.Context(), accountID, service.ReportDrilldownParams{Metric: metric, DimensionType: dimensionType, DimensionID: id, GroupBy: groupBy, Since: since, Until: until, BucketTimestamp: bucket, TimezoneOffset: offset, BusinessHours: parseReportBusinessHours(c), Page: page, PerPage: perPage}) if err != nil { if strings.Contains(err.Error(), "unsupported") || strings.Contains(err.Error(), "invalid bucket") { c.Status(http.StatusUnprocessableEntity) return } handleServiceError(c, err) 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) }