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