package handlers import ( "context" "encoding/json" "fmt" "net/http" "strconv" "time" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/models" "veza-backend-api/internal/services" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" ) // DashboardServiceInterface defines methods needed for dashboard handler type DashboardServiceInterface interface { GetStats(ctx context.Context, startDate, endDate time.Time) ([]*services.AuditStats, error) GetUserActivity(ctx context.Context, userID uuid.UUID, limit int) ([]*services.AuditLog, error) } // TrackListParamsForDashboard represents parameters for listing tracks (to avoid import cycle) type TrackListParamsForDashboard struct { Page int Limit int UserID *uuid.UUID Genre *string Format *string SortBy string SortOrder string } // TrackListFuncForDashboard is a function type for listing tracks (to avoid import cycle) type TrackListFuncForDashboard func(ctx context.Context, params TrackListParamsForDashboard) ([]*models.Track, int64, error) // TrackServiceInterfaceForDashboard defines methods needed for dashboard handler type TrackServiceInterfaceForDashboard interface { ListTracks(ctx context.Context, params TrackListParamsForDashboard) ([]*models.Track, int64, error) } // DashboardHandler handles dashboard aggregation endpoint // Action 2.1.1.2: Implements dashboard endpoint that aggregates multiple data sources type DashboardHandler struct { auditService DashboardServiceInterface trackService TrackServiceInterfaceForDashboard logger *zap.Logger } // NewDashboardHandler creates a new dashboard handler // trackListFunc is a function that lists tracks (provided by router to avoid import cycle) func NewDashboardHandler( auditService *services.AuditService, trackListFunc TrackListFuncForDashboard, logger *zap.Logger, ) *DashboardHandler { return &DashboardHandler{ auditService: &dashboardAuditServiceWrapper{auditService: auditService}, trackService: &dashboardTrackServiceWrapper{listTracksFunc: trackListFunc}, logger: logger, } } // NewDashboardHandlerWithInterface creates a new dashboard handler with interfaces (for testing) func NewDashboardHandlerWithInterface( auditService DashboardServiceInterface, trackService TrackServiceInterfaceForDashboard, logger *zap.Logger, ) *DashboardHandler { return &DashboardHandler{ auditService: auditService, trackService: trackService, logger: logger, } } // dashboardAuditServiceWrapper wraps *services.AuditService to implement DashboardServiceInterface type dashboardAuditServiceWrapper struct { auditService *services.AuditService } func (w *dashboardAuditServiceWrapper) GetStats(ctx context.Context, startDate, endDate time.Time) ([]*services.AuditStats, error) { return w.auditService.GetStats(ctx, startDate, endDate) } func (w *dashboardAuditServiceWrapper) GetUserActivity(ctx context.Context, userID uuid.UUID, limit int) ([]*services.AuditLog, error) { return w.auditService.GetUserActivity(ctx, userID, limit) } // dashboardTrackServiceWrapper wraps track service to implement TrackServiceInterfaceForDashboard // This wrapper will be created in the router to avoid import cycles type dashboardTrackServiceWrapper struct { listTracksFunc func(ctx context.Context, params TrackListParamsForDashboard) ([]*models.Track, int64, error) } func (w *dashboardTrackServiceWrapper) ListTracks(ctx context.Context, params TrackListParamsForDashboard) ([]*models.Track, int64, error) { return w.listTracksFunc(ctx, params) } // DashboardResponse represents the aggregated dashboard data // Action 2.1.1.2: Matches DASHBOARD_ENDPOINT_CONTRACT.md type DashboardResponse struct { Stats DashboardStats `json:"stats"` RecentActivity []RecentActivity `json:"recent_activity"` LibraryPreview LibraryPreview `json:"library_preview"` } // DashboardStats represents dashboard statistics type DashboardStats struct { TracksPlayed int64 `json:"tracks_played"` MessagesSent int64 `json:"messages_sent"` Favorites int64 `json:"favorites"` ActiveFriends int64 `json:"active_friends"` TracksPlayedChange *string `json:"tracks_played_change,omitempty"` MessagesSentChange *string `json:"messages_sent_change,omitempty"` FavoritesChange *string `json:"favorites_change,omitempty"` ActiveFriendsChange *string `json:"active_friends_change,omitempty"` Period string `json:"period"` } // RecentActivity represents a recent activity item type RecentActivity struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` Description *string `json:"description,omitempty"` Timestamp string `json:"timestamp"` Icon *string `json:"icon,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } // LibraryPreview represents library preview data type LibraryPreview struct { Items []TrackPreview `json:"items"` TotalCount int64 `json:"total_count"` HasMore bool `json:"has_more"` } // TrackPreview represents a track in library preview type TrackPreview struct { ID string `json:"id"` Title string `json:"title"` Artist string `json:"artist"` Duration int `json:"duration"` CoverArtPath *string `json:"cover_art_path,omitempty"` PlayCount int64 `json:"play_count"` LikeCount int64 `json:"like_count"` CreatedAt string `json:"created_at"` } // GetDashboard aggregates dashboard data from multiple sources // @Summary Get Dashboard Data // @Description Get aggregated dashboard data including stats, recent activity, and library preview // @Tags Dashboard // @Accept json // @Produce json // @Security BearerAuth // @Param activity_limit query int false "Number of recent activity items (default: 10)" // @Param library_limit query int false "Number of library items (default: 5)" // @Param stats_period query string false "Time period for statistics: 7d, 30d, 90d, all (default: 30d)" // @Success 200 {object} handlers.APIResponse{data=DashboardResponse} // @Failure 401 {object} handlers.APIResponse "Unauthorized" // @Failure 500 {object} handlers.APIResponse "Internal server error" // @Router /api/v1/dashboard [get] // Action 2.1.1.2: Implement dashboard aggregation endpoint func (h *DashboardHandler) GetDashboard() gin.HandlerFunc { return func(c *gin.Context) { // Get user ID from context userIDInterface, exists := c.Get("user_id") if !exists { RespondWithError(c, int(apperrors.ErrCodeUnauthorized), "User not authenticated") return } userID, ok := userIDInterface.(uuid.UUID) if !ok { RespondWithError(c, int(apperrors.ErrCodeInternal), "Invalid user ID type") return } // Parse query parameters activityLimit := 10 // Default if limitStr := c.Query("activity_limit"); limitStr != "" { if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 100 { activityLimit = parsed } } libraryLimit := 5 // Default if limitStr := c.Query("library_limit"); limitStr != "" { if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 50 { libraryLimit = parsed } } statsPeriod := c.DefaultQuery("stats_period", "30d") if statsPeriod != "7d" && statsPeriod != "30d" && statsPeriod != "90d" && statsPeriod != "all" { statsPeriod = "30d" // Default to 30d if invalid } // Calculate date range for stats var startDate, endDate time.Time endDate = time.Now() switch statsPeriod { case "7d": startDate = endDate.AddDate(0, 0, -7) case "30d": startDate = endDate.AddDate(0, 0, -30) case "90d": startDate = endDate.AddDate(0, 0, -90) case "all": startDate = time.Time{} // Zero time for "all time" } ctx := c.Request.Context() // Fetch data in parallel (as per contract performance considerations) type statsResult struct { stats []*services.AuditStats err error } type activityResult struct { activity []*services.AuditLog err error } type tracksResult struct { tracks []*models.Track total int64 err error } statsChan := make(chan statsResult, 1) activityChan := make(chan activityResult, 1) tracksChan := make(chan tracksResult, 1) // Fetch stats go func() { stats, err := h.auditService.GetStats(ctx, startDate, endDate) statsChan <- statsResult{stats: stats, err: err} }() // Fetch recent activity go func() { activity, err := h.auditService.GetUserActivity(ctx, userID, activityLimit) activityChan <- activityResult{activity: activity, err: err} }() // Fetch library preview go func() { tracks, total, err := h.trackService.ListTracks(ctx, TrackListParamsForDashboard{ UserID: &userID, Limit: libraryLimit, Page: 1, SortBy: "created_at", SortOrder: "desc", }) tracksChan <- tracksResult{tracks: tracks, total: total, err: err} }() // Collect results statsRes := <-statsChan if statsRes.err != nil { h.logger.Error("Failed to get dashboard stats", zap.Error(statsRes.err), zap.String("user_id", userID.String()), ) RespondWithError(c, int(apperrors.ErrCodeInternal), "Failed to fetch dashboard stats") return } activityRes := <-activityChan if activityRes.err != nil { h.logger.Error("Failed to get dashboard activity", zap.Error(activityRes.err), zap.String("user_id", userID.String()), ) RespondWithError(c, int(apperrors.ErrCodeInternal), "Failed to fetch dashboard activity") return } tracksRes := <-tracksChan if tracksRes.err != nil { h.logger.Error("Failed to get dashboard library preview", zap.Error(tracksRes.err), zap.String("user_id", userID.String()), ) RespondWithError(c, int(apperrors.ErrCodeInternal), "Failed to fetch library preview") return } // Aggregate stats from audit logs stats := h.aggregateStats(statsRes.stats, statsPeriod) // Convert activity logs to RecentActivity format recentActivity := h.convertActivityLogs(activityRes.activity) // Convert tracks to TrackPreview format libraryPreview := h.convertTracksToPreview(tracksRes.tracks, tracksRes.total, libraryLimit) // Build response response := DashboardResponse{ Stats: stats, RecentActivity: recentActivity, LibraryPreview: libraryPreview, } // Action 1.3.2.1: Use wrapped format helper RespondSuccess(c, http.StatusOK, response) } } // aggregateStats aggregates audit stats into dashboard stats format func (h *DashboardHandler) aggregateStats(auditStats []*services.AuditStats, period string) DashboardStats { stats := DashboardStats{ Period: period, } // Aggregate stats from audit logs for _, stat := range auditStats { switch stat.Action { case "play", "track_play": stats.TracksPlayed += stat.ActionCount case "message", "message_sent": stats.MessagesSent += stat.ActionCount case "favorite", "like", "track_like": stats.Favorites += stat.ActionCount } // Active friends: count unique users from interactions // This is a simplified calculation - could be enhanced if stat.UniqueUsers > stats.ActiveFriends { stats.ActiveFriends = stat.UniqueUsers } } // NOTE: Change percentages (vs previous period) could be added // This requires fetching stats for previous period, which is out of scope for initial implementation // Change percentages can be added in a follow-up task return stats } // convertActivityLogs converts audit logs to RecentActivity format func (h *DashboardHandler) convertActivityLogs(logs []*services.AuditLog) []RecentActivity { activities := make([]RecentActivity, 0, len(logs)) for _, log := range logs { activity := RecentActivity{ ID: log.ID.String(), Timestamp: log.Timestamp.Format(time.RFC3339), Metadata: make(map[string]interface{}), } // Parse metadata from json.RawMessage var metadataMap map[string]interface{} if len(log.Metadata) > 0 { if err := json.Unmarshal(log.Metadata, &metadataMap); err == nil { activity.Metadata = metadataMap } } // Map action/resource to activity type and title switch log.Action { case "upload", "track_upload": activity.Type = "track_upload" activity.Title = "Nouvelle piste ajoutée" if metadataMap != nil { if fileName, ok := metadataMap["file_name"].(string); ok { desc := fileName activity.Description = &desc } if trackID, ok := metadataMap["track_id"].(string); ok { activity.Metadata["track_id"] = trackID } } case "message", "message_received": activity.Type = "message_received" activity.Title = "Message reçu" if metadataMap != nil { if fromUser, ok := metadataMap["from_user_id"].(string); ok { activity.Metadata["from_user_id"] = fromUser } if convID, ok := metadataMap["conversation_id"].(string); ok { activity.Metadata["conversation_id"] = convID } } case "favorite", "like", "track_like": activity.Type = "favorite_added" activity.Title = "Piste ajoutée aux favoris" if metadataMap != nil { if trackID, ok := metadataMap["track_id"].(string); ok { activity.Metadata["track_id"] = trackID } } case "playlist_create": activity.Type = "playlist_created" activity.Title = "Playlist créée" if metadataMap != nil { if playlistID, ok := metadataMap["playlist_id"].(string); ok { activity.Metadata["playlist_id"] = playlistID } } case "comment", "comment_add": activity.Type = "comment_added" activity.Title = "Commentaire ajouté" if metadataMap != nil { if trackID, ok := metadataMap["track_id"].(string); ok { activity.Metadata["track_id"] = trackID } } default: // Generic activity activity.Type = "other" activity.Title = fmt.Sprintf("%s: %s", log.Action, log.Resource) } activities = append(activities, activity) } return activities } // convertTracksToPreview converts tracks to TrackPreview format func (h *DashboardHandler) convertTracksToPreview(tracks []*models.Track, total int64, limit int) LibraryPreview { previews := make([]TrackPreview, 0, len(tracks)) for _, track := range tracks { preview := TrackPreview{ ID: track.ID.String(), Title: track.Title, Artist: track.Artist, Duration: int(track.Duration), PlayCount: track.PlayCount, LikeCount: track.LikeCount, CreatedAt: track.CreatedAt.Format(time.RFC3339), } if track.CoverArtPath != "" { preview.CoverArtPath = &track.CoverArtPath } previews = append(previews, preview) } return LibraryPreview{ Items: previews, TotalCount: total, HasMore: int64(len(previews)) < total, } }