package handlers import ( "errors" "net/http" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" "veza-backend-api/internal/models" "veza-backend-api/internal/services" ) // UpgradeCreatorResponse is returned by POST /users/me/upgrade-creator. type UpgradeCreatorResponse struct { Role string `json:"role"` PromotedToCreatorAt *time.Time `json:"promoted_to_creator_at,omitempty"` AlreadyElevated bool `json:"already_elevated"` } // creatorOrHigher lists roles that already have creator-tier privileges. // Self-promotion is a no-op for these (idempotent 200). func creatorOrHigher(role string) bool { switch role { case "creator", "premium", "moderator", "admin", "artist", "producer", "label": return true } return false } // UpgradeToCreator returns the handler for POST /users/me/upgrade-creator. // Self-service path: a verified `role='user'` account flips to `role='creator'` // on one conscious click. One-way — downgrade requires a support ticket. // // Guards: // - 401 if no authenticated user (middleware should catch this first, belt+braces) // - 403 EMAIL_NOT_VERIFIED if `is_verified=false` // - 200 idempotent if the caller is already creator-tier or above // - 200 with the new role + promoted_to_creator_at if the flip happened func UpgradeToCreator(db *gorm.DB, auditService *services.AuditService, logger *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { userIDRaw, ok := c.Get("user_id") if !ok { c.JSON(http.StatusUnauthorized, gin.H{ "error": gin.H{"code": "UNAUTHORIZED", "message": "User ID not found in context"}, }) return } userID, ok := userIDRaw.(uuid.UUID) if !ok { c.JSON(http.StatusBadRequest, gin.H{ "error": gin.H{"code": "INVALID_USER_ID", "message": "Invalid user ID"}, }) return } ctx := c.Request.Context() var user models.User if err := db.WithContext(ctx).First(&user, "id = ?", userID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{"code": "USER_NOT_FOUND", "message": "User not found"}, }) return } logger.Error("Failed to load user for creator upgrade", zap.String("user_id", userID.String()), zap.Error(err), ) c.JSON(http.StatusInternalServerError, gin.H{ "error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to load user"}, }) return } if !user.IsVerified { c.JSON(http.StatusForbidden, gin.H{ "error": gin.H{ "code": "EMAIL_NOT_VERIFIED", "message": "Verify your email before upgrading to a creator account", }, }) return } if creatorOrHigher(user.Role) { // Already creator-tier. Keep the existing role and promoted_to_creator_at // (may be nil for admin-assigned roles — that's fine, the timestamp only // records self-promotions). c.JSON(http.StatusOK, UpgradeCreatorResponse{ Role: user.Role, PromotedToCreatorAt: user.PromotedToCreatorAt, AlreadyElevated: true, }) return } now := time.Now().UTC() updates := map[string]interface{}{ "role": "creator", "promoted_to_creator_at": now, "updated_at": now, } // Only flip if role is still 'user' — avoids racing with an admin who // may have assigned a different role between the load and the write. res := db.WithContext(ctx). Model(&models.User{}). Where("id = ? AND role = ?", userID, "user"). Updates(updates) if res.Error != nil { logger.Error("Failed to upgrade user to creator", zap.String("user_id", userID.String()), zap.Error(res.Error), ) c.JSON(http.StatusInternalServerError, gin.H{ "error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to upgrade user"}, }) return } if res.RowsAffected == 0 { // Race: role changed between load and write. Re-load and treat as // "already elevated" (the race landed on a higher role, which is fine). if reloadErr := db.WithContext(ctx).First(&user, "id = ?", userID).Error; reloadErr == nil { c.JSON(http.StatusOK, UpgradeCreatorResponse{ Role: user.Role, PromotedToCreatorAt: user.PromotedToCreatorAt, AlreadyElevated: true, }) return } logger.Warn("Creator upgrade UPDATE matched zero rows and reload failed", zap.String("user_id", userID.String()), ) c.JSON(http.StatusConflict, gin.H{ "error": gin.H{"code": "UPGRADE_CONFLICT", "message": "Role changed concurrently, please retry"}, }) return } // Log to audit trail (non-fatal on failure — upgrade already succeeded). if auditService != nil { if auditErr := auditService.LogAction(ctx, &services.AuditLogCreateRequest{ UserID: &userID, Action: "user.upgrade_creator", Resource: "user", IPAddress: c.ClientIP(), UserAgent: c.GetHeader("User-Agent"), Metadata: map[string]interface{}{ "from_role": "user", "to_role": "creator", "promoted_to_creator_at": now.Format(time.RFC3339), }, }); auditErr != nil { logger.Warn("Failed to log creator upgrade in audit trail", zap.String("user_id", userID.String()), zap.Error(auditErr), ) } } logger.Info("User upgraded to creator", zap.String("user_id", userID.String()), zap.Time("promoted_at", now), ) c.JSON(http.StatusOK, UpgradeCreatorResponse{ Role: "creator", PromotedToCreatorAt: &now, AlreadyElevated: false, }) } }