package analytics import ( "context" "encoding/json" "net/http" "strconv" "strings" "time" "veza-backend-api/internal/common" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/handlers" "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, }, }) } // GetCreatorExport handles GET /api/v1/analytics/creator/export?format=csv|json func (h *Handler) GetCreatorExport(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.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "analytics service unavailable")) return } format := strings.ToLower(c.DefaultQuery("format", "json")) if format != "csv" && format != "json" { handlers.RespondWithAppError(c, apperrors.NewValidationError("format must be csv or json")) return } days := 30 if d := c.Query("days"); d != "" { if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 { days = n } } endDate := time.Now() startDate := endDate.AddDate(0, 0, -days) ctx := c.Request.Context() db := analyticsSvc.GetDB() var rows []struct { TrackID uuid.UUID `gorm:"column:track_id"` Title string `gorm:"column:title"` Date string `gorm:"column:day"` Plays int64 `gorm:"column:cnt"` } db.WithContext(ctx).Table("track_plays tp"). Joins("JOIN tracks t ON t.id = tp.track_id"). Select("tp.track_id, t.title, DATE(tp.played_at) as day, COUNT(*) as cnt"). Where("t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?", userID, startDate, endDate). Group("tp.track_id, t.title, DATE(tp.played_at)"). Order("day ASC, cnt DESC"). Find(&rows) if format == "json" { exportRows := make([]gin.H, len(rows)) for i, r := range rows { exportRows[i] = gin.H{"track_id": r.TrackID.String(), "title": r.Title, "date": r.Date, "plays": r.Plays} } exportData := gin.H{ "period": gin.H{"start_date": startDate.Format(time.RFC3339), "end_date": endDate.Format(time.RFC3339), "days": days}, "rows": exportRows, } c.Header("Content-Disposition", "attachment; filename=veza-creator-export.json") c.Data(http.StatusOK, "application/json", []byte(mustJSON(exportData))) return } var b strings.Builder b.WriteString("track_id,title,date,plays\n") for _, r := range rows { b.WriteString(r.TrackID.String()) b.WriteString(",") b.WriteString(escapeCSV(r.Title)) b.WriteString(",") b.WriteString(r.Date) b.WriteString(",") b.WriteString(strconv.FormatInt(r.Plays, 10)) b.WriteString("\n") } c.Header("Content-Disposition", "attachment; filename=veza-creator-export.csv") c.Data(http.StatusOK, "text/csv", []byte(b.String())) } func escapeCSV(s string) string { if strings.ContainsAny(s, ",\"\n") { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } return s } func mustJSON(v interface{}) []byte { // Use simple JSON marshalling - gin has json package data, _ := json.Marshal(v) return data } // GetCreatorCharts handles GET /api/v1/analytics/creator/charts func (h *Handler) GetCreatorCharts(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{ "plays_per_day": []gin.H{}, "top_tracks": []gin.H{}, "period": gin.H{"start_date": "", "end_date": "", "days": 0}, }) return } daysStr := c.DefaultQuery("days", "30") days, _ := strconv.Atoi(daysStr) if days < 1 { days = 30 } endDate := time.Now() startDate := endDate.AddDate(0, 0, -days) ctx := c.Request.Context() db := analyticsSvc.GetDB() var dayRows []struct { Day string `gorm:"column:day"` Count int64 `gorm:"column:cnt"` } db.WithContext(ctx).Table("track_plays tp"). Joins("JOIN tracks t ON t.id = tp.track_id"). Select("DATE(tp.played_at) as day, COUNT(*) as cnt"). Where("t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?", userID, startDate, endDate). Group("DATE(tp.played_at)"). Order("day ASC"). Find(&dayRows) playsPerDay := make([]gin.H, len(dayRows)) for i, r := range dayRows { playsPerDay[i] = gin.H{"date": r.Day, "count": r.Count} } var topRows []struct { TrackID uuid.UUID `gorm:"column:track_id"` Title string `gorm:"column:title"` PlayCount int64 `gorm:"column:play_count"` } db.WithContext(ctx).Table("track_plays tp"). Joins("JOIN tracks t ON t.id = tp.track_id"). Select("tp.track_id, t.title, COUNT(*) as play_count"). Where("t.creator_id = ? AND tp.played_at >= ? AND tp.played_at <= ?", userID, startDate, endDate). Group("tp.track_id, t.title"). Order("play_count DESC"). Limit(10). Find(&topRows) topTracks := make([]gin.H, len(topRows)) for i, r := range topRows { topTracks[i] = gin.H{"track_id": r.TrackID.String(), "title": r.Title, "plays": r.PlayCount} } handlers.RespondSuccess(c, http.StatusOK, gin.H{ "plays_per_day": playsPerDay, "top_tracks": topTracks, "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, }) }