601 lines
18 KiB
Go
601 lines
18 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"veza-backend-api/internal/handlers"
|
|
"veza-backend-api/internal/common"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/services"
|
|
"veza-backend-api/internal/types"
|
|
)
|
|
|
|
// AnalyticsServiceInterface defines the interface for AnalyticsService
|
|
type AnalyticsServiceInterface interface {
|
|
GetTrackStats(ctx context.Context, trackID uuid.UUID) (*types.TrackStats, error)
|
|
GetPlaysOverTime(ctx context.Context, trackID uuid.UUID, startDate, endDate time.Time, interval string) ([]services.PlayTimePoint, error)
|
|
}
|
|
|
|
// AnalyticsJobWorkerInterface defines the interface for JobWorker (analytics related)
|
|
type AnalyticsJobWorkerInterface interface {
|
|
EnqueueAnalyticsJob(eventName string, userID *uuid.UUID, payload map[string]interface{})
|
|
}
|
|
|
|
// AnalyticsServiceWithDB extends AnalyticsServiceInterface with DB access for GetAnalytics
|
|
type AnalyticsServiceWithDB interface {
|
|
AnalyticsServiceInterface
|
|
GetDB() *gorm.DB
|
|
}
|
|
|
|
// Handler handles analytics HTTP requests (core domain, ADR-001)
|
|
type Handler struct {
|
|
analyticsService AnalyticsServiceInterface
|
|
jobWorker AnalyticsJobWorkerInterface
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewHandler creates a new analytics handler
|
|
func NewHandler(analyticsService *services.AnalyticsService, logger *zap.Logger) *Handler {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &Handler{
|
|
analyticsService: analyticsService,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// NewHandlerWithInterface creates a handler with interfaces (for testing)
|
|
func NewHandlerWithInterface(analyticsService AnalyticsServiceInterface, logger *zap.Logger) *Handler {
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &Handler{
|
|
analyticsService: analyticsService,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// SetJobWorker sets the JobWorker for recording analytics events
|
|
func (h *Handler) SetJobWorker(jobWorker AnalyticsJobWorkerInterface) {
|
|
h.jobWorker = jobWorker
|
|
}
|
|
|
|
// RecordEventRequest represents the request for recording a custom analytics event
|
|
type RecordEventRequest struct {
|
|
EventName string `json:"event_name" binding:"required,min=1,max=100" validate:"required,min=1,max=100"`
|
|
Payload map[string]interface{} `json:"payload,omitempty" validate:"omitempty"`
|
|
}
|
|
|
|
// RecordEvent handles POST /api/v1/analytics/events
|
|
func (h *Handler) RecordEvent(c *gin.Context) {
|
|
var req RecordEventRequest
|
|
if !common.BindAndValidateJSON(c, &req) {
|
|
return
|
|
}
|
|
|
|
var userID *uuid.UUID
|
|
if uid, ok := c.Get("user_id"); ok {
|
|
if uidUUID, ok := uid.(uuid.UUID); ok {
|
|
userID = &uidUUID
|
|
}
|
|
}
|
|
|
|
if h.jobWorker == nil {
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service not available"))
|
|
return
|
|
}
|
|
|
|
h.jobWorker.EnqueueAnalyticsJob(req.EventName, userID, req.Payload)
|
|
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
|
"message": "event recorded",
|
|
"event_name": req.EventName,
|
|
})
|
|
}
|
|
|
|
// GetTrafficSources handles GET /api/v1/analytics/traffic-sources
|
|
// Returns empty until traffic source tracking is implemented (track_plays has no source column)
|
|
func (h *Handler) GetTrafficSources(c *gin.Context) {
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{"sources": []gin.H{}})
|
|
}
|
|
|
|
// GetDeviceBreakdown handles GET /api/v1/analytics/device-breakdown
|
|
// Returns mobile/desktop counts from track_plays for the user's tracks
|
|
func (h *Handler) GetDeviceBreakdown(c *gin.Context) {
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
|
|
return
|
|
}
|
|
userID, ok := userIDInterface.(uuid.UUID)
|
|
if !ok {
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id"))
|
|
return
|
|
}
|
|
|
|
analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB)
|
|
if !ok {
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{"mobile": 0, "desktop": 0})
|
|
return
|
|
}
|
|
|
|
var deviceCounts []struct {
|
|
Device string `gorm:"column:device"`
|
|
Count int64 `gorm:"column:cnt"`
|
|
}
|
|
if err := analyticsSvc.GetDB().WithContext(c.Request.Context()).
|
|
Table("track_plays tp").
|
|
Select("tp.device, COUNT(*) as cnt").
|
|
Joins("JOIN tracks t ON t.id = tp.track_id").
|
|
Where("t.creator_id = ?", userID).
|
|
Group("tp.device").
|
|
Find(&deviceCounts).Error; err != nil {
|
|
h.logger.Warn("Failed to fetch device breakdown", zap.Error(err))
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{"mobile": 0, "desktop": 0})
|
|
return
|
|
}
|
|
|
|
var mobile, desktop int64
|
|
for _, d := range deviceCounts {
|
|
dev := strings.ToLower(strings.TrimSpace(d.Device))
|
|
if dev == "" || dev == "desktop" || dev == "web" {
|
|
desktop += d.Count
|
|
} else if dev == "mobile" || dev == "tablet" || strings.Contains(dev, "mobile") || strings.Contains(dev, "tablet") {
|
|
mobile += d.Count
|
|
} else {
|
|
desktop += d.Count
|
|
}
|
|
}
|
|
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
|
"mobile": mobile,
|
|
"desktop": desktop,
|
|
})
|
|
}
|
|
|
|
// GetCreatorStats handles GET /api/v1/analytics/creator/stats
|
|
// Aggregates playback_analytics for the authenticated creator's tracks
|
|
func (h *Handler) GetCreatorStats(c *gin.Context) {
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
|
|
return
|
|
}
|
|
userID, ok := userIDInterface.(uuid.UUID)
|
|
if !ok {
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id"))
|
|
return
|
|
}
|
|
|
|
analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB)
|
|
if !ok {
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
|
"total_plays": int64(0),
|
|
"unique_listeners": int64(0),
|
|
"average_completion_rate": float64(0),
|
|
"plays_by_day": []int64{},
|
|
"period": gin.H{"start_date": "", "end_date": "", "days": 0},
|
|
})
|
|
return
|
|
}
|
|
|
|
daysStr := c.DefaultQuery("days", "30")
|
|
days, err := strconv.Atoi(daysStr)
|
|
if err != nil || days < 1 {
|
|
days = 30
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, startDateStr); err == nil {
|
|
startDate = &parsed
|
|
}
|
|
}
|
|
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, endDateStr); err == nil {
|
|
endDate = &parsed
|
|
}
|
|
}
|
|
if startDate == nil || endDate == nil {
|
|
now := time.Now()
|
|
if endDate == nil {
|
|
endDate = &now
|
|
}
|
|
if startDate == nil {
|
|
calculatedStart := endDate.AddDate(0, 0, -days)
|
|
startDate = &calculatedStart
|
|
}
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
db := analyticsSvc.GetDB()
|
|
|
|
var totalPlays int64
|
|
db.WithContext(ctx).Table("playback_analytics pa").
|
|
Joins("JOIN tracks t ON t.id = pa.track_id").
|
|
Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate).
|
|
Count(&totalPlays)
|
|
|
|
var uniqueListeners int64
|
|
db.WithContext(ctx).Table("playback_analytics pa").
|
|
Joins("JOIN tracks t ON t.id = pa.track_id").
|
|
Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate).
|
|
Select("COUNT(DISTINCT pa.user_id)").
|
|
Scan(&uniqueListeners)
|
|
|
|
var avgCompletion float64
|
|
db.WithContext(ctx).Table("playback_analytics pa").
|
|
Joins("JOIN tracks t ON t.id = pa.track_id").
|
|
Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate).
|
|
Select("COALESCE(AVG(pa.completion_rate), 0)").
|
|
Scan(&avgCompletion)
|
|
|
|
var dayCounts []struct {
|
|
Day string `gorm:"column:day"`
|
|
Count int64 `gorm:"column:cnt"`
|
|
}
|
|
db.WithContext(ctx).Table("playback_analytics pa").
|
|
Joins("JOIN tracks t ON t.id = pa.track_id").
|
|
Select("DATE(pa.started_at) as day, COUNT(*) as cnt").
|
|
Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate).
|
|
Group("DATE(pa.started_at)").
|
|
Order("day ASC").
|
|
Find(&dayCounts)
|
|
|
|
playsByDay := make([]int64, len(dayCounts))
|
|
for i, d := range dayCounts {
|
|
playsByDay[i] = d.Count
|
|
}
|
|
if len(playsByDay) == 0 {
|
|
playsByDay = []int64{0}
|
|
}
|
|
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
|
"total_plays": totalPlays,
|
|
"unique_listeners": uniqueListeners,
|
|
"average_completion_rate": avgCompletion,
|
|
"plays_by_day": playsByDay,
|
|
"period": gin.H{
|
|
"start_date": startDate.Format(time.RFC3339),
|
|
"end_date": endDate.Format(time.RFC3339),
|
|
"days": days,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetAnalytics handles GET /api/v1/analytics
|
|
func (h *Handler) GetAnalytics(c *gin.Context) {
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
|
|
return
|
|
}
|
|
|
|
userID, ok := userIDInterface.(uuid.UUID)
|
|
if !ok {
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id"))
|
|
return
|
|
}
|
|
|
|
daysStr := c.DefaultQuery("days", "30")
|
|
days, err := strconv.Atoi(daysStr)
|
|
if err != nil || days < 1 {
|
|
days = 30
|
|
}
|
|
|
|
var startDate, endDate *time.Time
|
|
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, startDateStr); err == nil {
|
|
startDate = &parsed
|
|
}
|
|
}
|
|
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
|
if parsed, err := time.Parse(time.RFC3339, endDateStr); err == nil {
|
|
endDate = &parsed
|
|
}
|
|
}
|
|
|
|
if startDate == nil || endDate == nil {
|
|
now := time.Now()
|
|
if endDate == nil {
|
|
endDate = &now
|
|
}
|
|
if startDate == nil {
|
|
calculatedStart := endDate.AddDate(0, 0, -days)
|
|
startDate = &calculatedStart
|
|
}
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB)
|
|
if !ok {
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service type error"))
|
|
return
|
|
}
|
|
|
|
var tracks []struct {
|
|
ID uuid.UUID `gorm:"column:id"`
|
|
Title string `gorm:"column:title"`
|
|
PlayCount int64 `gorm:"column:play_count"`
|
|
LikeCount int64 `gorm:"column:like_count"`
|
|
DownloadCount int64 `gorm:"column:download_count"`
|
|
}
|
|
|
|
if err := analyticsSvc.GetDB().WithContext(ctx).
|
|
Table("tracks").
|
|
Select("id, title, play_count, like_count, COALESCE(download_count, 0) as download_count").
|
|
Where("creator_id = ?", userID).
|
|
Find(&tracks).Error; err != nil {
|
|
h.logger.Error("Failed to fetch user tracks", zap.Error(err))
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to fetch tracks"))
|
|
return
|
|
}
|
|
|
|
totalTracks := len(tracks)
|
|
var totalPlays, totalLikes, totalDownloads int64
|
|
for _, track := range tracks {
|
|
totalPlays += track.PlayCount
|
|
totalLikes += track.LikeCount
|
|
totalDownloads += track.DownloadCount
|
|
}
|
|
|
|
avgPlayCount := float64(0)
|
|
if totalTracks > 0 {
|
|
avgPlayCount = float64(totalPlays) / float64(totalTracks)
|
|
}
|
|
|
|
topTracks := make([]gin.H, 0, 5)
|
|
sortedTracks := make([]struct {
|
|
ID uuid.UUID
|
|
Title string
|
|
PlayCount int64
|
|
LikeCount int64
|
|
}, len(tracks))
|
|
for i, t := range tracks {
|
|
sortedTracks[i] = struct {
|
|
ID uuid.UUID
|
|
Title string
|
|
PlayCount int64
|
|
LikeCount int64
|
|
}{t.ID, t.Title, t.PlayCount, t.LikeCount}
|
|
}
|
|
|
|
for i := 0; i < len(sortedTracks)-1; i++ {
|
|
for j := i + 1; j < len(sortedTracks); j++ {
|
|
if sortedTracks[i].PlayCount < sortedTracks[j].PlayCount {
|
|
sortedTracks[i], sortedTracks[j] = sortedTracks[j], sortedTracks[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := 0; i < 5 && i < len(sortedTracks); i++ {
|
|
topTracks = append(topTracks, gin.H{
|
|
"id": sortedTracks[i].ID.String(),
|
|
"title": sortedTracks[i].Title,
|
|
"play_count": sortedTracks[i].PlayCount,
|
|
"like_count": sortedTracks[i].LikeCount,
|
|
"plays": sortedTracks[i].PlayCount,
|
|
"change": 0,
|
|
"revenue": 0.0,
|
|
})
|
|
}
|
|
|
|
var playlists []struct {
|
|
ID uuid.UUID `gorm:"column:id"`
|
|
Name string `gorm:"column:name"`
|
|
PlayCount int64 `gorm:"column:play_count"`
|
|
LikeCount int64 `gorm:"column:like_count"`
|
|
ShareCount int64 `gorm:"column:share_count"`
|
|
}
|
|
|
|
playlistsError := analyticsSvc.GetDB().WithContext(ctx).
|
|
Table("playlists").
|
|
Select("id, name, COALESCE(play_count, 0) as play_count, COALESCE(like_count, 0) as like_count, COALESCE(share_count, 0) as share_count").
|
|
Where("user_id = ?", userID).
|
|
Find(&playlists).Error
|
|
|
|
if playlistsError != nil {
|
|
h.logger.Warn("Failed to fetch user playlists, continuing with empty playlists data", zap.Error(playlistsError))
|
|
playlists = nil
|
|
}
|
|
|
|
totalPlaylists := len(playlists)
|
|
var playlistPlays, playlistLikes, playlistShares int64
|
|
for _, playlist := range playlists {
|
|
playlistPlays += playlist.PlayCount
|
|
playlistLikes += playlist.LikeCount
|
|
playlistShares += playlist.ShareCount
|
|
}
|
|
|
|
avgPlaylistPlayCount := float64(0)
|
|
if totalPlaylists > 0 {
|
|
avgPlaylistPlayCount = float64(playlistPlays) / float64(totalPlaylists)
|
|
}
|
|
|
|
topPlaylists := make([]gin.H, 0, 5)
|
|
sortedPlaylists := make([]struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
PlayCount int64
|
|
LikeCount int64
|
|
}, len(playlists))
|
|
for i, p := range playlists {
|
|
sortedPlaylists[i] = struct {
|
|
ID uuid.UUID
|
|
Name string
|
|
PlayCount int64
|
|
LikeCount int64
|
|
}{p.ID, p.Name, p.PlayCount, p.LikeCount}
|
|
}
|
|
|
|
for i := 0; i < len(sortedPlaylists)-1; i++ {
|
|
for j := i + 1; j < len(sortedPlaylists); j++ {
|
|
if sortedPlaylists[i].PlayCount < sortedPlaylists[j].PlayCount {
|
|
sortedPlaylists[i], sortedPlaylists[j] = sortedPlaylists[j], sortedPlaylists[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := 0; i < 5 && i < len(sortedPlaylists); i++ {
|
|
topPlaylists = append(topPlaylists, gin.H{
|
|
"id": sortedPlaylists[i].ID.String(),
|
|
"name": sortedPlaylists[i].Name,
|
|
"play_count": sortedPlaylists[i].PlayCount,
|
|
"like_count": sortedPlaylists[i].LikeCount,
|
|
})
|
|
}
|
|
|
|
// P3.2: Additional fields for frontend GlobalStats contract
|
|
var followersCount int64
|
|
if err := analyticsSvc.GetDB().WithContext(ctx).
|
|
Table("follows").
|
|
Where("followed_id = ?", userID).
|
|
Count(&followersCount).Error; err != nil {
|
|
h.logger.Warn("Failed to fetch followers count", zap.Error(err))
|
|
}
|
|
|
|
var totalRevenue float64
|
|
if err := analyticsSvc.GetDB().WithContext(ctx).
|
|
Table("order_items").
|
|
Select("COALESCE(SUM(oi.price), 0)").
|
|
Joins("JOIN orders o ON o.id = order_items.order_id").
|
|
Joins("JOIN products p ON p.id = order_items.product_id").
|
|
Where("p.seller_id = ? AND o.status IN ('completed', 'paid')", userID).
|
|
Scan(&totalRevenue).Error; err != nil {
|
|
h.logger.Warn("Failed to fetch seller revenue", zap.Error(err))
|
|
}
|
|
|
|
// Sparklines: plays per day for user's tracks (last N days)
|
|
var sparklinePlays []int64
|
|
var dayCounts []struct {
|
|
Day string `gorm:"column:day"`
|
|
Count int64 `gorm:"column:cnt"`
|
|
}
|
|
if err := analyticsSvc.GetDB().WithContext(ctx).
|
|
Table("track_plays tp").
|
|
Select("DATE(tp.played_at) as day, COUNT(*) as cnt").
|
|
Joins("JOIN tracks t ON t.id = tp.track_id").
|
|
Where("t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?", userID, startDate, endDate).
|
|
Group("DATE(tp.played_at)").
|
|
Order("day ASC").
|
|
Find(&dayCounts).Error; err == nil && len(dayCounts) > 0 {
|
|
sparklinePlays = make([]int64, len(dayCounts))
|
|
for i, d := range dayCounts {
|
|
sparklinePlays[i] = d.Count
|
|
}
|
|
}
|
|
if len(sparklinePlays) == 0 {
|
|
sparklinePlays = []int64{0}
|
|
}
|
|
|
|
// Trends: % change vs previous period (simplified: use 0 if no prior data)
|
|
trends := gin.H{
|
|
"plays": float64(0),
|
|
"revenue": float64(0),
|
|
"followers": float64(0),
|
|
"views": float64(0),
|
|
}
|
|
|
|
analyticsData := gin.H{
|
|
"tracks": gin.H{
|
|
"total_tracks": totalTracks,
|
|
"total_plays": totalPlays,
|
|
"total_likes": totalLikes,
|
|
"total_downloads": totalDownloads,
|
|
"average_play_count": avgPlayCount,
|
|
"top_tracks": topTracks,
|
|
},
|
|
"playlists": gin.H{
|
|
"total_playlists": totalPlaylists,
|
|
"total_plays": playlistPlays,
|
|
"total_likes": playlistLikes,
|
|
"total_shares": playlistShares,
|
|
"average_play_count": avgPlaylistPlayCount,
|
|
"top_playlists": topPlaylists,
|
|
},
|
|
"period": gin.H{
|
|
"start_date": startDate.Format(time.RFC3339),
|
|
"end_date": endDate.Format(time.RFC3339),
|
|
"days": days,
|
|
},
|
|
// P3.2: Frontend GlobalStats contract
|
|
"total_tracks": totalTracks,
|
|
"total_plays": totalPlays,
|
|
"total_revenue": totalRevenue,
|
|
"followers": followersCount,
|
|
"profile_views": 0,
|
|
"trends": trends,
|
|
"sparklines": gin.H{
|
|
"plays": sparklinePlays,
|
|
"revenue": []float64{0},
|
|
"followers": []int64{0},
|
|
"views": []int64{0},
|
|
},
|
|
}
|
|
|
|
handlers.RespondSuccess(c, http.StatusOK, analyticsData)
|
|
}
|
|
|
|
// GetTrackAnalyticsDashboard handles GET /api/v1/analytics/tracks/:id
|
|
func (h *Handler) GetTrackAnalyticsDashboard(c *gin.Context) {
|
|
trackIDStr := c.Param("id")
|
|
if trackIDStr == "" {
|
|
handlers.RespondWithAppError(c, apperrors.NewValidationError("track id is required"))
|
|
return
|
|
}
|
|
|
|
trackID, err := uuid.Parse(trackIDStr)
|
|
if err != nil {
|
|
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
|
|
return
|
|
}
|
|
|
|
stats, err := h.analyticsService.GetTrackStats(c.Request.Context(), trackID)
|
|
if err != nil {
|
|
if err.Error() == "track not found" {
|
|
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track"))
|
|
return
|
|
}
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get track stats", err))
|
|
return
|
|
}
|
|
|
|
startDate := time.Now().AddDate(0, 0, -30)
|
|
endDate := time.Now()
|
|
playsOverTime, err := h.analyticsService.GetPlaysOverTime(c.Request.Context(), trackID, startDate, endDate, "day")
|
|
if err != nil {
|
|
playsOverTime = []services.PlayTimePoint{}
|
|
}
|
|
|
|
dashboard := gin.H{
|
|
"track_id": trackID.String(),
|
|
"stats": gin.H{
|
|
"total_plays": stats.TotalPlays,
|
|
"unique_listeners": stats.UniqueListeners,
|
|
"average_duration": stats.AverageDuration,
|
|
"completion_rate": stats.CompletionRate,
|
|
},
|
|
"plays_over_time": playsOverTime,
|
|
"period": gin.H{
|
|
"start_date": startDate.Format(time.RFC3339),
|
|
"end_date": endDate.Format(time.RFC3339),
|
|
"days": 30,
|
|
},
|
|
}
|
|
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
|
"dashboard": dashboard,
|
|
})
|
|
}
|