veza/veza-backend-api/internal/api/routes_users.go
senke 9f4c2183a2 feat(backend,web): self-service creator role upgrade via /settings
First item of the v1.0.6 backlog surfaced by the v1.0.5 smoke test: a
brand-new account could register, verify email, and log in — but
attempting to upload hit a 403 because `role='user'` doesn't pass the
`RequireContentCreatorRole` middleware. The only way to get past that
gate was an admin DB update.

This commit wires the self-service path decided in the v1.0.6
specification:

  * One-way flip from `role='user'` to `role='creator'`, gated strictly
    on `is_verified=true` (the verification-email flow we restored in
    Fix 2 of the hardening sprint).
  * No KYC, no cooldown, no admin validation. The conscious click
    already requires ownership of the email address.
  * Downgrade is out of scope — a creator who wants back to `user`
    opens a support ticket. Avoids the "my uploads orphaned" edge case.

Backend
  * Migration `977_users_promoted_to_creator_at.sql`: nullable
    `TIMESTAMPTZ` column, partial index for non-null values. NULL
    preserves the semantic for users who never self-promoted
    (out-of-band admin assignments stay distinguishable from organic
    creators for audit/analytics).
  * `models.User`: new `PromotedToCreatorAt *time.Time` field.
  * `handlers.UpgradeToCreator(db, auditService, logger)`:
      - 401 if no `user_id` in context (belt-and-braces — middleware
        should catch this first)
      - 404 if the user row is missing
      - 403 `EMAIL_NOT_VERIFIED` when `is_verified=false`
      - 200 idempotent with `already_elevated=true` when the caller is
        already creator / premium / moderator / admin / artist /
        producer / label (same set accepted by
        `RequireContentCreatorRole`)
      - 200 with the new role + `promoted_to_creator_at` on the happy
        path. The UPDATE is scoped `WHERE role='user'` so a concurrent
        admin assignment can't be silently overwritten; the zero-rows
        case reloads and returns `already_elevated=true`.
      - audit logs a `user.upgrade_creator` action with IP, UA, and
        the role transition metadata. Non-fatal on failure — the
        upgrade itself already committed.
  * Route: `POST /api/v1/users/me/upgrade-creator` under the existing
    protected users group (RequireAuth + CSRF).

Frontend
  * `AccountSettingsCreatorCard`: new card in the Account tab of
    `/settings`. Completely hidden for users already on a creator-tier
    role (no "you're already a creator" clutter). Unverified users see
    a disabled-but-explanatory state with a "Resend verification"
    CTA to `/verify-email/resend`. Verified users see the "Become an
    artist" button, which POSTs to `/users/me/upgrade-creator` and
    refetches the user on success.
  * `upgradeToCreator()` service in `features/settings/services/`.
  * Copy is deliberately explicit that the change is one-way.

Tests
  * 6 Go unit tests covering: happy path (role + timestamp), unverified
    refused, already-creator idempotent (timestamp preserved),
    admin-assigned idempotent (no timestamp overwrite), user-not-found,
    no-auth-context.
  * 7 Vitest tests covering: verified button visible, unverified state
    shown, card hidden for creator, card hidden for admin, success +
    refetch, idempotent message, server error via toast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:35:07 +02:00

296 lines
12 KiB
Go

package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
trackcore "veza-backend-api/internal/core/track"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/repositories"
"veza-backend-api/internal/services"
)
// setupUserRoutes configure les routes utilisateur
func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
userRepo := repositories.NewGormUserRepository(r.db.GormDB)
userService := services.NewUserServiceWithDB(userRepo, r.db.GormDB)
socialService := services.NewSocialService(r.db, r.logger)
userService.SetSocialService(socialService) // v0.10.0 F187: profiles get is_following, counts
profileHandler := handlers.NewProfileHandler(userService, r.logger)
if r.config != nil && r.config.PermissionService != nil {
profileHandler.SetPermissionService(r.config.PermissionService)
}
profileHandler.SetSocialService(socialService)
if r.notificationService == nil {
r.notificationService = services.NewNotificationService(r.db, r.logger)
if r.pushService != nil {
r.notificationService.SetPushService(r.pushService)
}
}
profileHandler.SetNotificationService(r.notificationService)
users := router.Group("/users")
{
users.GET("", profileHandler.ListUsers)
// v0.10.0 F211: /suggestions must be before /:id to avoid "suggestions" matching as id
if r.config.AuthMiddleware != nil {
users.GET("/suggestions", r.config.AuthMiddleware.RequireAuth(), profileHandler.GetFollowSuggestions)
}
users.GET("/:id", profileHandler.GetProfile)
users.GET("/by-username/:username", profileHandler.GetProfileByUsername)
users.GET("/search", profileHandler.SearchUsers)
// v0.10.3 F203: User reposts (public - profile shows reposted tracks)
uploadDir := "uploads/tracks"
var redisClient *redis.Client
streamServerURL, streamAPIKey := "", ""
if r.config != nil {
if r.config.UploadDir != "" {
uploadDir = r.config.UploadDir
}
redisClient = r.config.RedisClient
streamServerURL = r.config.StreamServerURL
streamAPIKey = r.config.StreamServerInternalAPIKey
}
repostService := services.NewTrackRepostService(r.db.GormDB)
trackServiceForReposts := trackcore.NewTrackServiceWithDB(r.db, r.logger, uploadDir)
if r.config != nil && r.config.CacheService != nil {
trackServiceForReposts.SetCacheService(r.config.CacheService)
}
trackHandlerForReposts := trackcore.NewTrackHandler(
trackServiceForReposts,
services.NewTrackUploadService(r.db.GormDB, r.logger),
services.NewTrackChunkService(uploadDir+"/chunks", redisClient, r.logger),
services.NewTrackLikeService(r.db.GormDB, r.logger),
services.NewStreamServiceWithAPIKey(streamServerURL, streamAPIKey, r.logger),
)
trackHandlerForReposts.SetRepostService(repostService)
users.GET("/:id/reposts", trackHandlerForReposts.GetUserRepostedTracks)
if r.config.AuthMiddleware != nil {
protected := users.Group("")
protected.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(protected)
settingsHandler := handlers.NewSettingsHandler(userService, r.logger)
protected.GET("/settings", settingsHandler.GetSettings)
protected.PUT("/settings", settingsHandler.UpdateSettings)
userOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
userIDStr := c.Param("id")
return uuid.Parse(userIDStr)
}
protected.PUT("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("user", userOwnerResolver), profileHandler.UpdateProfile)
protected.DELETE("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("user", userOwnerResolver), profileHandler.DeleteUser)
protected.GET("/:id/completion", profileHandler.GetProfileCompletion)
presenceService := services.NewPresenceService(r.db.GormDB, r.logger)
presenceHandler := handlers.NewPresenceHandler(presenceService, r.logger)
protected.PUT("/me/presence", presenceHandler.UpdatePresence)
protected.GET("/:id/presence", presenceHandler.GetPresence)
protected.POST("/:id/follow", profileHandler.FollowUser)
protected.DELETE("/:id/follow", profileHandler.UnfollowUser)
protected.POST("/:id/block", profileHandler.BlockUser)
protected.DELETE("/:id/block", profileHandler.UnblockUser)
roleService := services.NewRoleService(r.db.GormDB)
roleHandler := handlers.NewRoleHandler(roleService, r.logger)
protected.POST("/:id/roles", roleHandler.AssignRole)
protected.DELETE("/:id/roles/:roleId", roleHandler.RevokeRole)
avatarUploadDir := r.config.UploadDir
if avatarUploadDir == "" {
avatarUploadDir = "uploads/avatars"
}
imageService := services.NewImageService(avatarUploadDir)
avatarHandler := handlers.NewAvatarHandler(imageService, userService)
protected.POST("/:id/avatar", avatarHandler.UploadAvatar)
protected.DELETE("/:id/avatar", avatarHandler.DeleteAvatar)
uploadDir := r.config.UploadDir
if uploadDir == "" {
uploadDir = "uploads/tracks"
}
likeService := services.NewTrackLikeService(r.db.GormDB, r.logger)
trackService := trackcore.NewTrackServiceWithDB(r.db, r.logger, uploadDir)
if r.config.CacheService != nil {
trackService.SetCacheService(r.config.CacheService)
}
streamService := services.NewStreamServiceWithAPIKey(r.config.StreamServerURL, r.config.StreamServerInternalAPIKey, r.logger)
trackService.SetStreamService(streamService) // INT-02: Enable HLS pipeline for regular uploads
trackUploadService := services.NewTrackUploadService(r.db.GormDB, r.logger)
var redisClient *redis.Client
if r.config != nil {
redisClient = r.config.RedisClient
}
chunksDir := uploadDir + "/chunks"
chunkService := services.NewTrackChunkService(chunksDir, redisClient, r.logger)
trackHandlerForLikes := trackcore.NewTrackHandler(
trackService,
trackUploadService,
chunkService,
likeService,
streamService,
)
protected.GET("/:id/likes", trackHandlerForLikes.GetUserLikedTracks)
dataExportService := services.NewDataExportService(r.db.GormDB, r.logger)
gdprExportService := services.NewGDPRExportService(
r.db.GormDB, dataExportService, r.config.S3StorageService, r.notificationService, r.logger,
)
emailSvc := services.NewEmailService(r.db, r.logger)
gdprExportService.SetEmailService(emailSvc)
var gdprRedis *redis.Client
var gdprS3 *services.S3StorageService
if r.config != nil {
gdprRedis = r.config.RedisClient
gdprS3 = r.config.S3StorageService
}
gdprExportHandler := handlers.NewGDPRExportHandler(
r.db.GormDB, gdprExportService, gdprS3, gdprRedis, r.logger,
)
// PUT /me/password: Change password
protected.PUT("/me/password", func(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "UNAUTHORIZED", "message": "User ID not found"}})
return
}
userUUID, ok := userID.(uuid.UUID)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "INVALID_USER_ID", "message": "Invalid user ID"}})
return
}
var req struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "INVALID_REQUEST", "message": "Current password and new password are required"}})
return
}
if len(req.NewPassword) < 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "WEAK_PASSWORD", "message": "Password must be at least 12 characters long"}})
return
}
var currentHash string
if err := r.db.GormDB.Raw("SELECT password_hash FROM users WHERE id = ?", userUUID).Scan(&currentHash).Error; err != nil {
r.logger.Error("Failed to get current password hash", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to verify password"}})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.CurrentPassword)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "WRONG_PASSWORD", "message": "Current password is incorrect"}})
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
r.logger.Error("Failed to hash new password", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to update password"}})
return
}
if err := r.db.GormDB.Exec("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", string(newHash), userUUID).Error; err != nil {
r.logger.Error("Failed to update password", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to update password"}})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password changed successfully"})
})
// GET /me/export: sync JSON fallback (v0.10.8 - prefer POST for async ZIP)
exportHandler := func(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User ID not found"})
return
}
userUUID, ok := userID.(uuid.UUID)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
return
}
jsonData, err := dataExportService.ExportUserDataAsJSON(c.Request.Context(), userUUID)
if err != nil {
r.logger.Error("Failed to export user data", zap.Error(err), zap.String("user_id", userUUID.String()))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to export user data"})
return
}
filename := "veza-data-export-" + time.Now().Format("2006-01-02T15-04-05") + ".json"
c.Header("Content-Type", "application/json")
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
c.Header("Content-Length", strconv.Itoa(len(jsonData)))
c.Data(http.StatusOK, "application/json", jsonData)
}
protected.GET("/me/export", exportHandler)
// POST /me/export: async GDPR export (v0.10.8 F065)
protected.POST("/me/export", gdprExportHandler.RequestExport)
// GET /me/exports: list exports (v0.10.8)
protected.GET("/me/exports", gdprExportHandler.ListExports)
// GET /me/exports/:id/download: redirect to S3 presigned URL (must be before :id)
protected.GET("/me/exports/:id/download", gdprExportHandler.DownloadExport)
// GET /me/exports/:id: export status
protected.GET("/me/exports/:id", gdprExportHandler.GetExport)
// DELETE /me: Account deletion (v0.803 SEC2-05)
protected.DELETE("/me", handlers.DeleteAccountHandler(
r.db.GormDB,
r.config.SessionService,
r.config.AuditService,
r.config.S3StorageService,
r.logger,
))
// POST /me/privacy/opt-out: CCPA Do Not Sell (v0.803 SEC2-06)
protected.POST("/me/privacy/opt-out", handlers.PrivacyOptOut(r.db.GormDB))
// POST /me/upgrade-creator: self-service creator role (v1.0.6)
// Requires is_verified=true; one-way flip from 'user' to 'creator'.
var upgradeAudit *services.AuditService
if r.config != nil {
upgradeAudit = r.config.AuditService
}
protected.POST("/me/upgrade-creator", handlers.UpgradeToCreator(r.db.GormDB, upgradeAudit, r.logger))
}
}
}
// setupRoleRoutes configure les routes de gestion des rôles
func (r *APIRouter) setupRoleRoutes(router *gin.RouterGroup) {
roleService := services.NewRoleService(r.db.GormDB)
roleHandler := handlers.NewRoleHandler(roleService, r.logger)
roles := router.Group("/roles")
{
if r.config.AuthMiddleware != nil {
protected := roles.Group("")
protected.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(protected)
{
protected.GET("", roleHandler.GetRoles)
protected.GET("/:id", roleHandler.GetRole)
protected.POST("", roleHandler.CreateRole)
protected.PUT("/:id", roleHandler.UpdateRole)
protected.DELETE("/:id", roleHandler.DeleteRole)
}
}
}
}