556 lines
17 KiB
Go
556 lines
17 KiB
Go
package v1
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"net/http"
|
|
"strconv"
|
|
"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
|
|
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "type parameter is required")
|
|
return
|
|
}
|
|
|
|
result, err := h.svc.GetConversationsByType(c.Request.Context(), accountID, reportType, time.Time{}, time.Time{})
|
|
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
|
|
}
|
|
|
|
result, err := h.svc.GetInboxLabelMatrix(c.Request.Context(), accountID)
|
|
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)
|
|
}
|
|
|
|
// 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 := parseDateRange(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)
|
|
}
|
|
|
|
// 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" {
|
|
response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrBadRequest, "invalid group_by")
|
|
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)
|
|
}
|