feat(backend,web): self-service creator role upgrade via /settings
Some checks failed
Veza CI / Backend (Go) (push) Failing after 0s
Veza CI / Frontend (Web) (push) Failing after 0s
Veza CI / Rust (Stream Server) (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 0s
Veza CI / Notify on failure (push) Failing after 0s

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>
This commit is contained in:
senke 2026-04-16 18:35:07 +02:00
parent f601441aa3
commit c32278dc11
9 changed files with 615 additions and 0 deletions

View file

@ -7,6 +7,7 @@ import { AccountSettingsErrorBanner } from './AccountSettingsErrorBanner';
import { AccountSettingsPasswordCard } from './AccountSettingsPasswordCard';
import { AccountSettingsExportCard } from './AccountSettingsExportCard';
import { AccountSettingsDeleteCard } from './AccountSettingsDeleteCard';
import { AccountSettingsCreatorCard } from './AccountSettingsCreatorCard';
import { TwoFactorSettings } from '../TwoFactorSettings';
export function AccountSettings() {
@ -66,6 +67,8 @@ export function AccountSettings() {
onSubmit={handleChangePassword}
/>
<AccountSettingsCreatorCard />
<TwoFactorSettings />
<AccountSettingsExportCard onExport={handleExportData} />

View file

@ -0,0 +1,113 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AccountSettingsCreatorCard } from './AccountSettingsCreatorCard';
const mockRefetch = vi.fn();
const mockUpgrade = vi.fn();
const mockToastError = vi.fn();
const mockToastSuccess = vi.fn();
vi.mock('@/features/auth/hooks/useUser', () => ({
useUser: () => ({ data: (globalThis as any).__testUser, refetch: mockRefetch }),
}));
vi.mock('../../services/creatorUpgradeService', () => ({
upgradeToCreator: () => mockUpgrade(),
}));
vi.mock('@/utils/toast', () => ({
default: {
error: (msg: string) => mockToastError(msg),
success: (msg: string) => mockToastSuccess(msg),
},
}));
beforeEach(() => {
mockRefetch.mockReset();
mockUpgrade.mockReset();
mockToastError.mockReset();
mockToastSuccess.mockReset();
});
const setUser = (overrides: Record<string, unknown>) => {
(globalThis as any).__testUser = {
id: 'u1',
username: 'test',
email: 't@example.com',
role: 'user',
is_verified: true,
is_active: true,
is_admin: false,
is_public: true,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
};
};
describe('AccountSettingsCreatorCard', () => {
it('renders the upgrade button when user is verified and role=user', () => {
setUser({});
render(<AccountSettingsCreatorCard />);
expect(screen.getByTestId('creator-upgrade-card')).toBeInTheDocument();
expect(screen.getByTestId('creator-upgrade-submit')).toBeEnabled();
});
it('shows the unverified state when email is not verified', () => {
setUser({ is_verified: false });
render(<AccountSettingsCreatorCard />);
expect(screen.getByTestId('creator-upgrade-unverified')).toBeInTheDocument();
expect(screen.queryByTestId('creator-upgrade-submit')).not.toBeInTheDocument();
});
it('hides the entire card for creator-tier roles', () => {
setUser({ role: 'creator' });
const { container } = render(<AccountSettingsCreatorCard />);
expect(container).toBeEmptyDOMElement();
});
it('hides the entire card for admins (already creator-tier)', () => {
setUser({ role: 'admin' });
const { container } = render(<AccountSettingsCreatorCard />);
expect(container).toBeEmptyDOMElement();
});
it('calls upgradeToCreator and refetches the user on click', async () => {
setUser({});
mockUpgrade.mockResolvedValueOnce({
role: 'creator',
promoted_to_creator_at: '2026-04-16T14:00:00Z',
already_elevated: false,
});
render(<AccountSettingsCreatorCard />);
fireEvent.click(screen.getByTestId('creator-upgrade-submit'));
await waitFor(() => expect(mockUpgrade).toHaveBeenCalledTimes(1));
expect(mockToastSuccess).toHaveBeenCalledWith(expect.stringContaining('Welcome'));
expect(mockRefetch).toHaveBeenCalledTimes(1);
});
it('shows the idempotent success message when already elevated', async () => {
setUser({});
mockUpgrade.mockResolvedValueOnce({
role: 'creator',
promoted_to_creator_at: null,
already_elevated: true,
});
render(<AccountSettingsCreatorCard />);
fireEvent.click(screen.getByTestId('creator-upgrade-submit'));
await waitFor(() => expect(mockToastSuccess).toHaveBeenCalled());
expect(mockToastSuccess).toHaveBeenCalledWith(expect.stringContaining('already a creator'));
});
it('surfaces server errors via toast.error', async () => {
setUser({});
mockUpgrade.mockRejectedValueOnce(new Error('network down'));
render(<AccountSettingsCreatorCard />);
fireEvent.click(screen.getByTestId('creator-upgrade-submit'));
await waitFor(() => expect(mockToastError).toHaveBeenCalled());
expect(mockToastError).toHaveBeenCalledWith('network down');
});
});

View file

@ -0,0 +1,120 @@
/**
* AccountSettingsCreatorCard v1.0.6.
*
* Self-service upgrade path from `role: 'user'` to `role: 'creator'`.
* Hidden for users who are already creator-tier. Gated on `is_verified`:
* unverified users see a "verify your email first" state with a resend
* link instead of the upgrade button.
*
* Product decision: one-way. No downgrade UI a creator who wants back
* to `user` opens a support ticket. Keeps us out of the "my uploads
* orphaned" edge case.
*/
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import toast from '@/utils/toast';
import { Sparkles, BadgeCheck, Mail } from 'lucide-react';
import { useUser } from '@/features/auth/hooks/useUser';
import { upgradeToCreator } from '../../services/creatorUpgradeService';
const CREATOR_TIER_ROLES = new Set([
'creator',
'premium',
'moderator',
'admin',
'artist',
'producer',
'label',
]);
export function AccountSettingsCreatorCard() {
const { data: user, refetch } = useUser();
const [isUpgrading, setIsUpgrading] = useState(false);
if (!user) return null;
// Already creator-tier — hide the card. Nothing to offer here.
if (CREATOR_TIER_ROLES.has(user.role)) return null;
const handleUpgrade = async () => {
if (!user.is_verified) {
toast.error('Verify your email before upgrading to a creator account.');
return;
}
setIsUpgrading(true);
try {
const result = await upgradeToCreator();
if (result.already_elevated) {
toast.success('Your account was already a creator — nothing to change.');
} else {
toast.success('Welcome aboard. You can now upload tracks.');
}
// Pull the fresh user (new role) so other UI updates immediately.
void refetch();
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to upgrade account.';
toast.error(message);
} finally {
setIsUpgrading(false);
}
};
return (
<Card
variant="glass"
className="p-6 space-y-4 border-primary/20"
data-testid="creator-upgrade-card"
>
<div className="flex items-start gap-3">
<div className="shrink-0 rounded-lg bg-primary/10 p-2">
<Sparkles className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-base flex items-center gap-2">
Become an artist
</h3>
<p className="text-sm text-muted-foreground mt-1">
Upgrade your account to a creator profile so you can upload tracks
and publish releases. This is a one-way change; to revert, contact
support.
</p>
</div>
</div>
{user.is_verified ? (
<div className="flex items-center justify-between gap-4 pt-2">
<div className="flex items-center gap-2 text-sm text-success-foreground">
<BadgeCheck className="h-4 w-4 text-success" />
Email verified you&apos;re ready to upgrade.
</div>
<Button
onClick={handleUpgrade}
disabled={isUpgrading}
data-testid="creator-upgrade-submit"
>
{isUpgrading ? 'Upgrading…' : 'Become an artist'}
</Button>
</div>
) : (
<div
className="flex items-center justify-between gap-4 pt-2 rounded-lg border border-warning/40 bg-warning/5 p-3"
data-testid="creator-upgrade-unverified"
>
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-warning" />
<span>Verify your email before upgrading to an artist account.</span>
</div>
<Button variant="outline" size="sm" asChild>
<a href="/verify-email/resend">Resend verification</a>
</Button>
</div>
)}
</Card>
);
}
export default AccountSettingsCreatorCard;

View file

@ -0,0 +1,22 @@
/**
* Creator upgrade service v1.0.6 self-service path for `role: 'user'` accounts.
*
* Hits POST /api/v1/users/me/upgrade-creator. The backend guards on
* `is_verified=true` (403 EMAIL_NOT_VERIFIED otherwise) and idempotently
* returns 200 when the caller is already creator-tier.
*/
import { apiClient } from '@/services/api/client';
export interface UpgradeCreatorResponse {
role: string;
promoted_to_creator_at?: string | null;
already_elevated: boolean;
}
export async function upgradeToCreator(): Promise<UpgradeCreatorResponse> {
const response = await apiClient.post<UpgradeCreatorResponse>(
'/users/me/upgrade-creator',
);
return response.data;
}

View file

@ -261,6 +261,14 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
// 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))
}
}
}

View file

@ -0,0 +1,176 @@
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,
})
}
}

View file

@ -0,0 +1,154 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"veza-backend-api/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap/zaptest"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupUpgradeCreatorTest(t *testing.T) (*gin.Engine, *gorm.DB, uuid.UUID) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
db.Exec("PRAGMA foreign_keys = ON")
require.NoError(t, db.AutoMigrate(&models.User{}))
userID := uuid.New()
// Default seed: verified user with role=user. Individual tests can override.
require.NoError(t, db.Create(&models.User{
ID: userID,
Username: "upgrader",
Email: "upgrade@example.com",
Role: "user",
IsVerified: true,
}).Error)
logger := zaptest.NewLogger(t)
router := gin.New()
router.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
return router, db, userID
}
func postUpgrade(t *testing.T, router *gin.Engine) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/users/me/upgrade-creator", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func TestUpgradeToCreator_VerifiedUserIsPromoted(t *testing.T) {
router, db, userID := setupUpgradeCreatorTest(t)
w := postUpgrade(t, router)
assert.Equal(t, http.StatusOK, w.Code)
var resp UpgradeCreatorResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "creator", resp.Role)
assert.False(t, resp.AlreadyElevated)
require.NotNil(t, resp.PromotedToCreatorAt)
assert.WithinDuration(t, time.Now(), *resp.PromotedToCreatorAt, 5*time.Second)
var after models.User
require.NoError(t, db.First(&after, "id = ?", userID).Error)
assert.Equal(t, "creator", after.Role)
require.NotNil(t, after.PromotedToCreatorAt)
}
func TestUpgradeToCreator_UnverifiedIsRefused(t *testing.T) {
router, db, userID := setupUpgradeCreatorTest(t)
// Flip is_verified=false; default seed had it true.
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Update("is_verified", false).Error)
w := postUpgrade(t, router)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), "EMAIL_NOT_VERIFIED")
var after models.User
require.NoError(t, db.First(&after, "id = ?", userID).Error)
assert.Equal(t, "user", after.Role, "role must not flip when email is not verified")
assert.Nil(t, after.PromotedToCreatorAt)
}
func TestUpgradeToCreator_AlreadyCreatorIsIdempotent(t *testing.T) {
router, db, userID := setupUpgradeCreatorTest(t)
earlier := time.Now().Add(-48 * time.Hour).UTC()
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
"role": "creator",
"promoted_to_creator_at": earlier,
}).Error)
w := postUpgrade(t, router)
assert.Equal(t, http.StatusOK, w.Code)
var resp UpgradeCreatorResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "creator", resp.Role)
assert.True(t, resp.AlreadyElevated)
require.NotNil(t, resp.PromotedToCreatorAt)
assert.WithinDuration(t, earlier, *resp.PromotedToCreatorAt, time.Second,
"idempotent path must preserve the original promotion timestamp")
}
func TestUpgradeToCreator_AdminIsIdempotentWithoutTimestamp(t *testing.T) {
router, db, userID := setupUpgradeCreatorTest(t)
// Admin was assigned out-of-band — no promoted_to_creator_at.
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Update("role", "admin").Error)
w := postUpgrade(t, router)
assert.Equal(t, http.StatusOK, w.Code)
var resp UpgradeCreatorResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "admin", resp.Role, "admin must keep their role, not be downgraded to creator")
assert.True(t, resp.AlreadyElevated)
assert.Nil(t, resp.PromotedToCreatorAt, "admin assigned without the self-service flow has no timestamp")
}
func TestUpgradeToCreator_UserNotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.User{}))
logger := zaptest.NewLogger(t)
router := gin.New()
// Inject a user_id that doesn't exist in the DB
router.Use(func(c *gin.Context) { c.Set("user_id", uuid.New()); c.Next() })
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
w := postUpgrade(t, router)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "USER_NOT_FOUND")
}
func TestUpgradeToCreator_NoAuthContext(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.User{}))
logger := zaptest.NewLogger(t)
router := gin.New()
// No middleware that sets user_id
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
w := postUpgrade(t, router)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "UNAUTHORIZED")
}

View file

@ -35,6 +35,10 @@ type User struct {
LastLoginAt *time.Time `json:"last_login_at" db:"last_login_at"`
LoginCount int `gorm:"default:0;not null" json:"login_count" db:"login_count"`
PasswordChangedAt *time.Time `json:"password_changed_at,omitempty" db:"password_changed_at"` // F016: Password expiration tracking
// v1.0.6: set the first time a user self-promotes to `role='creator'`
// via POST /api/v1/users/me/upgrade-creator. NULL for users who never
// took that path (still 'user', or promoted by an admin out-of-band).
PromotedToCreatorAt *time.Time `json:"promoted_to_creator_at,omitempty" db:"promoted_to_creator_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" db:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" db:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`

View file

@ -0,0 +1,15 @@
-- Migration 977: Track when a user was promoted to the 'creator' role (v1.0.6)
-- Follow-up to the v1.0.5 audit: upload was gated on role=creator with no
-- self-service path. v1.0.6 adds POST /api/v1/users/me/upgrade-creator; this
-- column records when that one-way transition happened so audit + analytics
-- can distinguish organic creators from legacy role assignments.
--
-- NULL = user has never been self-promoted (either still role='user' or
-- was assigned a higher role by an admin without going through the flow).
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS promoted_to_creator_at TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_users_promoted_to_creator_at
ON public.users(promoted_to_creator_at)
WHERE promoted_to_creator_at IS NOT NULL;