veza/veza-backend-api/internal/handlers/dashboard.go

453 lines
15 KiB
Go
Raw Normal View History

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,
}
}