veza/veza-backend-api/internal/handlers/upgrade_creator_handler.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

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,
})
}
}