177 lines
5.5 KiB
Go
177 lines
5.5 KiB
Go
|
|
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,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|