299 lines
8.8 KiB
Go
299 lines
8.8 KiB
Go
|
|
package analytics
|
||
|
|
|
||
|
|
import (
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
apperrors "veza-backend-api/internal/errors"
|
||
|
|
"veza-backend-api/internal/handlers"
|
||
|
|
"veza-backend-api/internal/services"
|
||
|
|
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"go.uber.org/zap"
|
||
|
|
)
|
||
|
|
|
||
|
|
// AdvancedAnalyticsHandler handles advanced analytics HTTP requests (F396-F399)
|
||
|
|
type AdvancedAnalyticsHandler struct {
|
||
|
|
service *services.AdvancedAnalyticsService
|
||
|
|
logger *zap.Logger
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewAdvancedAnalyticsHandler creates a new advanced analytics handler
|
||
|
|
func NewAdvancedAnalyticsHandler(service *services.AdvancedAnalyticsService, logger *zap.Logger) *AdvancedAnalyticsHandler {
|
||
|
|
if logger == nil {
|
||
|
|
logger = zap.NewNop()
|
||
|
|
}
|
||
|
|
return &AdvancedAnalyticsHandler{service: service, logger: logger}
|
||
|
|
}
|
||
|
|
|
||
|
|
// getCreatorID extracts and validates the creator user ID from context
|
||
|
|
func (h *AdvancedAnalyticsHandler) getCreatorID(c *gin.Context) (uuid.UUID, bool) {
|
||
|
|
userIDInterface, exists := c.Get("user_id")
|
||
|
|
if !exists {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required"))
|
||
|
|
return uuid.Nil, false
|
||
|
|
}
|
||
|
|
userID, ok := userIDInterface.(uuid.UUID)
|
||
|
|
if !ok {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id"))
|
||
|
|
return uuid.Nil, false
|
||
|
|
}
|
||
|
|
return userID, true
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetTrackHeatmap handles GET /api/v1/creator/analytics/heatmap/:trackId (F396)
|
||
|
|
func (h *AdvancedAnalyticsHandler) GetTrackHeatmap(c *gin.Context) {
|
||
|
|
creatorID, ok := h.getCreatorID(c)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
trackIDStr := c.Param("trackId")
|
||
|
|
trackID, err := uuid.Parse(trackIDStr)
|
||
|
|
if err != nil {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
startDate, endDate := parseDateRange(c)
|
||
|
|
|
||
|
|
heatmap, err := h.service.GetTrackHeatmap(c.Request.Context(), creatorID, trackID, startDate, endDate)
|
||
|
|
if err != nil {
|
||
|
|
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "not owned") {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get heatmap", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||
|
|
"heatmap": heatmap,
|
||
|
|
"period": gin.H{
|
||
|
|
"start_date": startDate.Format(time.RFC3339),
|
||
|
|
"end_date": endDate.Format(time.RFC3339),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// ComparePeriods handles GET /api/v1/creator/analytics/compare (F397)
|
||
|
|
func (h *AdvancedAnalyticsHandler) ComparePeriods(c *gin.Context) {
|
||
|
|
creatorID, ok := h.getCreatorID(c)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse current period
|
||
|
|
now := time.Now()
|
||
|
|
preset := c.DefaultQuery("preset", "week") // week, month, quarter
|
||
|
|
var currentStart, currentEnd, previousStart, previousEnd time.Time
|
||
|
|
|
||
|
|
switch preset {
|
||
|
|
case "month":
|
||
|
|
currentEnd = now
|
||
|
|
currentStart = now.AddDate(0, -1, 0)
|
||
|
|
previousEnd = currentStart
|
||
|
|
previousStart = currentStart.AddDate(0, -1, 0)
|
||
|
|
case "quarter":
|
||
|
|
currentEnd = now
|
||
|
|
currentStart = now.AddDate(0, -3, 0)
|
||
|
|
previousEnd = currentStart
|
||
|
|
previousStart = currentStart.AddDate(0, -3, 0)
|
||
|
|
default: // week
|
||
|
|
currentEnd = now
|
||
|
|
currentStart = now.AddDate(0, 0, -7)
|
||
|
|
previousEnd = currentStart
|
||
|
|
previousStart = currentStart.AddDate(0, 0, -7)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Allow custom date overrides
|
||
|
|
if s := c.Query("current_start"); s != "" {
|
||
|
|
if parsed, err := time.Parse(time.RFC3339, s); err == nil {
|
||
|
|
currentStart = parsed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if e := c.Query("current_end"); e != "" {
|
||
|
|
if parsed, err := time.Parse(time.RFC3339, e); err == nil {
|
||
|
|
currentEnd = parsed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if s := c.Query("previous_start"); s != "" {
|
||
|
|
if parsed, err := time.Parse(time.RFC3339, s); err == nil {
|
||
|
|
previousStart = parsed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if e := c.Query("previous_end"); e != "" {
|
||
|
|
if parsed, err := time.Parse(time.RFC3339, e); err == nil {
|
||
|
|
previousEnd = parsed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
comparison, err := h.service.ComparePeriods(c.Request.Context(), creatorID, currentStart, currentEnd, previousStart, previousEnd)
|
||
|
|
if err != nil {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to compare periods", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||
|
|
"comparison": comparison,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetMarketplaceAnalytics handles GET /api/v1/creator/analytics/marketplace (F398)
|
||
|
|
func (h *AdvancedAnalyticsHandler) GetMarketplaceAnalytics(c *gin.Context) {
|
||
|
|
creatorID, ok := h.getCreatorID(c)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
startDate, endDate := parseDateRange(c)
|
||
|
|
|
||
|
|
analytics, err := h.service.GetMarketplaceAnalytics(c.Request.Context(), creatorID, startDate, endDate)
|
||
|
|
if err != nil {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get marketplace analytics", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||
|
|
"marketplace": analytics,
|
||
|
|
"period": gin.H{
|
||
|
|
"start_date": startDate.Format(time.RFC3339),
|
||
|
|
"end_date": endDate.Format(time.RFC3339),
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetMetricAlerts handles GET /api/v1/creator/analytics/alerts (F399)
|
||
|
|
func (h *AdvancedAnalyticsHandler) GetMetricAlerts(c *gin.Context) {
|
||
|
|
creatorID, ok := h.getCreatorID(c)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
summary, err := h.service.GetMetricAlerts(c.Request.Context(), creatorID)
|
||
|
|
if err != nil {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get alerts", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||
|
|
"alerts": summary,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// updateAlertPreferenceRequest is the request body for updating alert preferences
|
||
|
|
type updateAlertPreferenceRequest struct {
|
||
|
|
MetricType string `json:"metric_type" binding:"required"`
|
||
|
|
Enabled bool `json:"enabled"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// UpdateAlertPreference handles PUT /api/v1/creator/analytics/alerts/preferences (F399)
|
||
|
|
func (h *AdvancedAnalyticsHandler) UpdateAlertPreference(c *gin.Context) {
|
||
|
|
creatorID, ok := h.getCreatorID(c)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var req updateAlertPreferenceRequest
|
||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid request body"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := h.service.UpdateAlertPreference(c.Request.Context(), creatorID, req.MetricType, req.Enabled); err != nil {
|
||
|
|
if strings.Contains(err.Error(), "invalid metric type") {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update preference", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||
|
|
"status": "updated",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// createAlertRequest is the request body for creating a metric alert
|
||
|
|
type createAlertRequest struct {
|
||
|
|
MetricType string `json:"metric_type" binding:"required"`
|
||
|
|
Threshold int64 `json:"threshold" binding:"required,min=1"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// CreateAlert handles POST /api/v1/creator/analytics/alerts (F399)
|
||
|
|
func (h *AdvancedAnalyticsHandler) CreateAlert(c *gin.Context) {
|
||
|
|
creatorID, ok := h.getCreatorID(c)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
var req createAlertRequest
|
||
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid request body"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := h.service.CreateAlertThreshold(c.Request.Context(), creatorID, req.MetricType, req.Threshold); err != nil {
|
||
|
|
if strings.Contains(err.Error(), "invalid metric type") || strings.Contains(err.Error(), "threshold must be positive") {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to create alert", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
handlers.RespondSuccess(c, http.StatusCreated, gin.H{
|
||
|
|
"status": "created",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeleteAlert handles DELETE /api/v1/creator/analytics/alerts/:alertId (F399)
|
||
|
|
func (h *AdvancedAnalyticsHandler) DeleteAlert(c *gin.Context) {
|
||
|
|
creatorID, ok := h.getCreatorID(c)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
alertIDStr := c.Param("alertId")
|
||
|
|
alertID, err := uuid.Parse(alertIDStr)
|
||
|
|
if err != nil {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid alert id"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := h.service.DeleteAlertThreshold(c.Request.Context(), creatorID, alertID); err != nil {
|
||
|
|
if strings.Contains(err.Error(), "not found") {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.NewNotFoundError("alert"))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete alert", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||
|
|
"status": "deleted",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// CheckAlerts handles POST /api/v1/creator/analytics/alerts/check (F399)
|
||
|
|
// Triggers alert checking for the current user
|
||
|
|
func (h *AdvancedAnalyticsHandler) CheckAlerts(c *gin.Context) {
|
||
|
|
creatorID, ok := h.getCreatorID(c)
|
||
|
|
if !ok {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
triggered, err := h.service.CheckAndTriggerAlerts(c.Request.Context(), creatorID)
|
||
|
|
if err != nil {
|
||
|
|
handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to check alerts", err))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
handlers.RespondSuccess(c, http.StatusOK, gin.H{
|
||
|
|
"triggered": triggered,
|
||
|
|
"count": len(triggered),
|
||
|
|
})
|
||
|
|
}
|