veza/veza-backend-api/internal/core/analytics/advanced_handler.go
senke 29586b59da feat(v0.11.1): F396-F399 advanced analytics service, handler and routes
- F396: Track listening heatmap (segment-level aggregated data)
- F397: Period comparison (week/month/quarter with % changes)
- F398: Marketplace analytics (product views, conversion rates, revenue)
- F399: Metric alerts (opt-in thresholds, preferences, CRUD)
- Unit tests for service (percent change calculations) and handler (auth, validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 17:12:26 +01:00

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