2026-02-14 17:04:37 +00:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
|
"go.uber.org/zap"
|
2026-03-25 22:39:28 +00:00
|
|
|
"golang.org/x/crypto/bcrypt"
|
2026-02-14 17:04:37 +00:00
|
|
|
|
|
|
|
|
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)
|
feat(v0.10.3): Commentaires & Interactions Sociales - F201-F215
- F201: Commentaires avec timestamp cliquable, modération mots-clés
- F202: Likes privés (compteur visible créateur uniquement)
- F203: Reposts de tracks sur le profil, bouton Repost, onglet Reposts
- F204: Notifications (commentaire, repost), pas de gamification
Backend: migrations 127/128, comment_moderation_service, track_repost_service,
GetTrackLikes/GetTrack masquent like_count pour non-créateurs
Frontend: LikeButton isCreator, RepostButton, Reposts tab profil, timestamp seek
2026-03-09 09:30:47 +00:00
|
|
|
socialService := services.NewSocialService(r.db, r.logger)
|
|
|
|
|
userService.SetSocialService(socialService) // v0.10.0 F187: profiles get is_following, counts
|
2026-02-14 17:04:37 +00:00
|
|
|
profileHandler := handlers.NewProfileHandler(userService, r.logger)
|
|
|
|
|
if r.config != nil && r.config.PermissionService != nil {
|
|
|
|
|
profileHandler.SetPermissionService(r.config.PermissionService)
|
|
|
|
|
}
|
|
|
|
|
profileHandler.SetSocialService(socialService)
|
2026-02-21 15:41:39 +00:00
|
|
|
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)
|
2026-02-14 17:04:37 +00:00
|
|
|
|
|
|
|
|
users := router.Group("/users")
|
|
|
|
|
{
|
|
|
|
|
users.GET("", profileHandler.ListUsers)
|
feat(v0.10.3): Commentaires & Interactions Sociales - F201-F215
- F201: Commentaires avec timestamp cliquable, modération mots-clés
- F202: Likes privés (compteur visible créateur uniquement)
- F203: Reposts de tracks sur le profil, bouton Repost, onglet Reposts
- F204: Notifications (commentaire, repost), pas de gamification
Backend: migrations 127/128, comment_moderation_service, track_repost_service,
GetTrackLikes/GetTrack masquent like_count pour non-créateurs
Frontend: LikeButton isCreator, RepostButton, Reposts tab profil, timestamp seek
2026-03-09 09:30:47 +00:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-02-14 17:04:37 +00:00
|
|
|
users.GET("/:id", profileHandler.GetProfile)
|
|
|
|
|
users.GET("/by-username/:username", profileHandler.GetProfileByUsername)
|
|
|
|
|
users.GET("/search", profileHandler.SearchUsers)
|
|
|
|
|
|
feat(v0.10.3): Commentaires & Interactions Sociales - F201-F215
- F201: Commentaires avec timestamp cliquable, modération mots-clés
- F202: Likes privés (compteur visible créateur uniquement)
- F203: Reposts de tracks sur le profil, bouton Repost, onglet Reposts
- F204: Notifications (commentaire, repost), pas de gamification
Backend: migrations 127/128, comment_moderation_service, track_repost_service,
GetTrackLikes/GetTrack masquent like_count pour non-créateurs
Frontend: LikeButton isCreator, RepostButton, Reposts tab profil, timestamp seek
2026-03-09 09:30:47 +00:00
|
|
|
// 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)
|
|
|
|
|
|
2026-02-14 17:04:37 +00:00
|
|
|
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)
|
|
|
|
|
|
feat(search): faceted filters (genre/key/BPM/year) + FacetSidebar UI (W4 Day 18)
Backend
- services/search_service.go : new SearchFilters struct (Genre,
MusicalKey, BPMMin, BPMMax, YearFrom, YearTo) + appendTrackFacets
helper that composes additional AND clauses onto the existing FTS
WHERE condition. Filters apply ONLY to the track query — users +
playlists ignore them silently (no relevant columns).
- handlers/search_handlers.go : new parseSearchFilters reads + bounds-
checks query params (BPM in [1,999], year in [1900,2100], min<=max).
Search() now passes filters into the service ; OTel span attribute
search.filtered surfaces whether facets were applied.
- elasticsearch/search_service.go : signature updated to match the
interface ; ES path doesn't translate facets yet (different filter
DSL needed) — logs a warning when facets arrive on this path.
- handlers/search_handlers_test.go : MockSearchService.Search updated
+ 4 mock.On call sites pass mock.Anything for the new filters arg.
Frontend
- services/api/search.ts : new SearchFacets shape ; searchApi.search
accepts an opts.facets bag. When non-empty, bypasses orval's typed
getSearch (its GetSearchParams pre-dates the new query params) and
uses apiClient.get directly with snake_case keys matching the
backend's parseSearchFilters().
- features/search/components/FacetSidebar.tsx (new) : sidebar with
genre + musical_key inputs (datalist suggestions), BPM min/max
pair, year from/to pair. Stateless ; SearchPage owns state.
data-testids on every control for E2E.
- features/search/components/search-page/useSearchPage.ts : facets
state stored in URL (genre, musical_key, bpm_min, bpm_max,
year_from, year_to) so deep links reproduce the result set.
300 ms debounce on facet changes.
- features/search/components/search-page/SearchPage.tsx : layout
switches to a 2-column grid (sidebar + results) when query is
non-empty ; discovery view keeps the full width when empty.
Collateral cleanup
- internal/api/routes_users.go : removed unused strconv + time
imports that were blocking the build (pre-existing dead imports
surfaced by the SearchServiceInterface signature change).
E2E
- tests/e2e/32-faceted-search.spec.ts : 4 tests. (36) backend rejects
bpm_min > bpm_max with 400. (37) out-of-range BPM rejected. (38)
valid range returns 200 with a tracks array. (39) UI — typing in
the sidebar updates URL query params within the 300 ms debounce.
Acceptance (Day 18) : promtool not relevant ; backend test suite
green for handlers + services + api ; TS strict pass ; E2E spec
covers the gates the roadmap acceptance asked for. The 'rock + BPM
120-130 = restricted results' assertion needs seed data with measurable
BPM (none today) — flagged in the spec as a follow-up to un-skip
once seed BPM data lands.
W4 progress : Day 16 done · Day 17 done · Day 18 done · Day 19
(HAProxy sticky WS) pending · Day 20 (k6 nightly) pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:33:35 +00:00
|
|
|
// v0.801: UI preferences (theme, contrast, etc.)
|
|
|
|
|
protected.GET("/me/preferences", settingsHandler.GetPreferences)
|
|
|
|
|
protected.PUT("/me/preferences", settingsHandler.UpdatePreferences)
|
|
|
|
|
|
2026-02-14 17:04:37 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-02-21 04:22:43 +00:00
|
|
|
presenceService := services.NewPresenceService(r.db.GormDB, r.logger)
|
|
|
|
|
presenceHandler := handlers.NewPresenceHandler(presenceService, r.logger)
|
2026-02-21 15:47:09 +00:00
|
|
|
protected.PUT("/me/presence", presenceHandler.UpdatePresence)
|
2026-02-21 04:22:43 +00:00
|
|
|
protected.GET("/:id/presence", presenceHandler.GetPresence)
|
|
|
|
|
|
2026-02-14 17:04:37 +00:00
|
|
|
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)
|
2026-02-14 21:50:23 +00:00
|
|
|
trackService := trackcore.NewTrackServiceWithDB(r.db, r.logger, uploadDir)
|
2026-02-14 17:04:37 +00:00
|
|
|
if r.config.CacheService != nil {
|
|
|
|
|
trackService.SetCacheService(r.config.CacheService)
|
|
|
|
|
}
|
2026-02-22 16:52:39 +00:00
|
|
|
streamService := services.NewStreamServiceWithAPIKey(r.config.StreamServerURL, r.config.StreamServerInternalAPIKey, r.logger)
|
|
|
|
|
trackService.SetStreamService(streamService) // INT-02: Enable HLS pipeline for regular uploads
|
2026-02-14 17:04:37 +00:00
|
|
|
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)
|
2026-03-10 12:57:04 +00:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-25 22:39:28 +00:00
|
|
|
// 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(¤tHash).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"})
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-10 12:57:04 +00:00
|
|
|
// GET /me/export: sync JSON fallback (v0.10.8 - prefer POST for async ZIP)
|
feat(search): faceted filters (genre/key/BPM/year) + FacetSidebar UI (W4 Day 18)
Backend
- services/search_service.go : new SearchFilters struct (Genre,
MusicalKey, BPMMin, BPMMax, YearFrom, YearTo) + appendTrackFacets
helper that composes additional AND clauses onto the existing FTS
WHERE condition. Filters apply ONLY to the track query — users +
playlists ignore them silently (no relevant columns).
- handlers/search_handlers.go : new parseSearchFilters reads + bounds-
checks query params (BPM in [1,999], year in [1900,2100], min<=max).
Search() now passes filters into the service ; OTel span attribute
search.filtered surfaces whether facets were applied.
- elasticsearch/search_service.go : signature updated to match the
interface ; ES path doesn't translate facets yet (different filter
DSL needed) — logs a warning when facets arrive on this path.
- handlers/search_handlers_test.go : MockSearchService.Search updated
+ 4 mock.On call sites pass mock.Anything for the new filters arg.
Frontend
- services/api/search.ts : new SearchFacets shape ; searchApi.search
accepts an opts.facets bag. When non-empty, bypasses orval's typed
getSearch (its GetSearchParams pre-dates the new query params) and
uses apiClient.get directly with snake_case keys matching the
backend's parseSearchFilters().
- features/search/components/FacetSidebar.tsx (new) : sidebar with
genre + musical_key inputs (datalist suggestions), BPM min/max
pair, year from/to pair. Stateless ; SearchPage owns state.
data-testids on every control for E2E.
- features/search/components/search-page/useSearchPage.ts : facets
state stored in URL (genre, musical_key, bpm_min, bpm_max,
year_from, year_to) so deep links reproduce the result set.
300 ms debounce on facet changes.
- features/search/components/search-page/SearchPage.tsx : layout
switches to a 2-column grid (sidebar + results) when query is
non-empty ; discovery view keeps the full width when empty.
Collateral cleanup
- internal/api/routes_users.go : removed unused strconv + time
imports that were blocking the build (pre-existing dead imports
surfaced by the SearchServiceInterface signature change).
E2E
- tests/e2e/32-faceted-search.spec.ts : 4 tests. (36) backend rejects
bpm_min > bpm_max with 400. (37) out-of-range BPM rejected. (38)
valid range returns 200 with a tracks array. (39) UI — typing in
the sidebar updates URL query params within the 300 ms debounce.
Acceptance (Day 18) : promtool not relevant ; backend test suite
green for handlers + services + api ; TS strict pass ; E2E spec
covers the gates the roadmap acceptance asked for. The 'rock + BPM
120-130 = restricted results' assertion needs seed data with measurable
BPM (none today) — flagged in the spec as a follow-up to un-skip
once seed BPM data lands.
W4 progress : Day 16 done · Day 17 done · Day 18 done · Day 19
(HAProxy sticky WS) pending · Day 20 (k6 nightly) pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:33:35 +00:00
|
|
|
protected.GET("/me/export", gdprExportHandler.ExportJSON)
|
2026-02-25 12:35:16 +00:00
|
|
|
|
2026-03-10 12:57:04 +00:00
|
|
|
// 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)
|
|
|
|
|
|
2026-02-25 18:51:21 +00:00
|
|
|
// 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,
|
|
|
|
|
))
|
|
|
|
|
|
2026-02-25 18:49:25 +00:00
|
|
|
// POST /me/privacy/opt-out: CCPA Do Not Sell (v0.803 SEC2-06)
|
|
|
|
|
protected.POST("/me/privacy/opt-out", handlers.PrivacyOptOut(r.db.GormDB))
|
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 16:35:07 +00:00
|
|
|
|
|
|
|
|
// 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))
|
2026-02-14 17:04:37 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
chore: consolidate CI, E2E, backend and frontend updates
- CI: workflows updates (cd, ci), remove playwright.yml
- E2E: global-setup, auth/playlists/profile specs
- Remove playwright-report and test-results artifacts from tracking
- Backend: auth, handlers, services, workers, migrations
- Frontend: components, features, vite config
- Add e2e-results.json to gitignore
- Docs: REMEDIATION_PROGRESS, audit archive
- Rust: chat-server, stream-server updates
2026-02-17 15:43:21 +00:00
|
|
|
protected.POST("", roleHandler.CreateRole)
|
|
|
|
|
protected.PUT("/:id", roleHandler.UpdateRole)
|
|
|
|
|
protected.DELETE("/:id", roleHandler.DeleteRole)
|
2026-02-14 17:04:37 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|