Sistema de Gerenciamento de Usuario
Visao Geral
O modulo de usuario do ProCases expoe 4 endpoints autenticados para consulta de perfil, estatisticas, saldo e atualizacao de Trade URL. Todos os endpoints requerem sessao ativa (cookie sessionId validado pelo AuthGuard).
A arquitetura segue o padrao de use cases da Clean Architecture: o controller delega para use cases que acessam repositorios e servicos.
Arquitetura de Arquivos
| Camada | Arquivo | Responsabilidade |
|---|---|---|
| Presentation | src/presentation/controllers/user.controller.ts | Controller HTTP |
| Presentation | src/presentation/modules/user.module.ts | Modulo NestJS |
| Application | src/application/use-cases/user/get-user-profile.use-case.ts | Consulta de perfil |
| Application | src/application/use-cases/user/get-user-stats.use-case.ts | Estatisticas do usuario |
| Application | src/application/use-cases/user/get-user-balance.use-case.ts | Consulta de saldo |
| Application | src/application/use-cases/user/update-trade-url.use-case.ts | Atualizacao de Trade URL |
| Application | src/application/dto/user.dto.ts | DTOs e validacao |
| Application | src/application/services/money.service.ts | Formatacao monetaria |
Endpoints
GET /user/profile
Retorna o perfil completo do usuario autenticado.
Autenticacao: AuthGuard (session ID via cookie)
Response (200) - UserProfileDto:
{
id: string; // Snowflake ID como string
steamId: string; // Steam ID 64 bits
username: string; // Nome de exibicao
avatarUrl: string; // URL do avatar Steam
profileUrl: string; // URL do perfil Steam
balanceCents: string; // Saldo em centavos BRL (cache)
balance: string; // Saldo formatado "1863.21"
tickets: number; // Tickets de evento
status: string; // Status da conta (ACTIVE, BANNED, etc.)
twoFactorEnabled: boolean; // 2FA habilitado
referralCode: string | null; // Codigo de indicacao
tradeUrl: string | null; // Trade URL da Steam
createdAt: string; // Data de criacao ISO 8601
}Implementacao:
@Injectable()
export class GetUserProfileUseCase {
constructor(
private userRepository: UserRepository,
private moneyService: MoneyService,
) {}
async execute(userId: bigint): Promise<UserProfileOutput> {
const user = await this.userRepository.findById(userId);
if (!user) throw new NotFoundException('User not found');
return {
id: user.id.toString(),
steamId: user.steamId,
username: user.username,
avatarUrl: user.avatarUrl || '',
profileUrl: user.profileUrl || '',
balanceCents: user.balanceCents.toString(),
balance: this.moneyService.toReais(user.balanceCents).toFixed(2),
tickets: user.tickets,
status: user.status,
twoFactorEnabled: user.twoFactorEnabled,
referralCode: user.referralCode,
tradeUrl: user.tradeUrl || null,
createdAt: user.createdAt.toISOString(),
};
}
}ATENCAO - Source of Truth do Saldo: Este endpoint usa user.balanceCents (cache da tabela User) e NAO o TransactionService.getUserBalance(). Isso e intencional para este endpoint especifico, pois o perfil e uma consulta de leitura rapida. Para operacoes financeiras (debito, credito, validacao antes de aposta), SEMPRE use o TransactionService.
Veja a documentacao em CLAUDE.md - secao "Sistema de Saldo - Source of Truth" para detalhes completos.
GET /user/stats
Retorna estatisticas financeiras e de atividade do usuario.
Autenticacao: AuthGuard
Response (200) - UserStatsDto:
{
totalDepositedCents: string; // Total depositado em centavos BRL
totalDeposited: string; // Total depositado formatado "500.00"
totalWithdrawnCents: string; // Total sacado em centavos BRL
totalWithdrawn: string; // Total sacado formatado "300.00"
totalWageredCents: string; // Total apostado em centavos BRL
totalWagered: string; // Total apostado formatado "1000.00"
netProfitCents: string; // Lucro liquido em centavos BRL
netProfit: string; // Lucro liquido formatado
referralCount: number; // Quantidade de usuarios indicados
}Implementacao:
@Injectable()
export class GetUserStatsUseCase {
constructor(
private userRepository: UserRepository,
private moneyService: MoneyService,
private prisma: PrismaService,
) {}
async execute(userId: bigint): Promise<UserStatsOutput> {
const user = await this.userRepository.findById(userId);
if (!user) throw new NotFoundException('User not found');
const referralCount = await this.prisma.user.count({
where: { referredById: userId },
});
const netProfitCents =
user.totalDepositedCents - user.totalWithdrawnCents - user.totalWageredCents;
return {
totalDepositedCents: user.totalDepositedCents.toString(),
totalDeposited: this.moneyService.toReais(user.totalDepositedCents).toFixed(2),
totalWithdrawnCents: user.totalWithdrawnCents.toString(),
totalWithdrawn: this.moneyService.toReais(user.totalWithdrawnCents).toFixed(2),
totalWageredCents: user.totalWageredCents.toString(),
totalWagered: this.moneyService.toReais(user.totalWageredCents).toFixed(2),
netProfitCents: netProfitCents.toString(),
netProfit: this.moneyService.toReais(netProfitCents).toFixed(2),
referralCount,
};
}
}Calculo do Net Profit:
netProfit = totalDeposited - totalWithdrawn - totalWageredO referralCount e calculado via query direta no Prisma, contando usuarios que possuem referredById igual ao userId do usuario atual.
Campos da tabela User utilizados:
user.totalDepositedCents(BigInt) - acumulado de depositosuser.totalWithdrawnCents(BigInt) - acumulado de saquesuser.totalWageredCents(BigInt) - acumulado de apostas
Esses campos sao incrementados atomicamente durante cada operacao financeira.
GET /user/balance
Retorna o saldo do usuario em multiplos formatos.
Autenticacao: AuthGuard
Response (200) - UserBalanceDto:
{
balanceCents: string; // Saldo em centavos "186321"
balance: string; // Saldo decimal "1863.21"
balanceFormatted: string; // Saldo formatado "R$ 1.863,21"
tickets: number; // Tickets de evento disponiveis
}Implementacao:
@Injectable()
export class GetUserBalanceUseCase {
constructor(
private userRepository: UserRepository,
private moneyService: MoneyService,
) {}
async execute(userId: bigint): Promise<UserBalanceOutput> {
const user = await this.userRepository.findById(userId);
if (!user) throw new NotFoundException('User not found');
return {
balanceCents: user.balanceCents.toString(),
balance: this.moneyService.toReais(user.balanceCents).toFixed(2),
balanceFormatted: this.moneyService.format(user.balanceCents),
tickets: user.tickets,
};
}
}Formatos de saldo retornados:
| Campo | Exemplo | Uso |
|---|---|---|
balanceCents | "186321" | Para calculos no frontend (parseInt) |
balance | "1863.21" | Formato decimal puro |
balanceFormatted | "R$ 1.863,21" | Exibicao direta para o usuario |
MoneyService:
toReais(cents): converte centavos BigInt para numero decimal (186321n->1863.21)format(cents): formata com simbolo e separadores brasileiros (186321n->"R$ 1.863,21")
PATCH /user/trade-url
Atualiza a Trade URL do usuario na Steam. Implementa validacao em 3 camadas para garantir seguranca e propriedade.
Autenticacao: AuthGuard
Body - UpdateTradeUrlDto:
{
tradeUrl: string // @IsString() @IsNotEmpty()
}Response (200): Retorna UserProfileDto (perfil atualizado).
Erros:
| Status | Mensagem | Causa |
|---|---|---|
| 400 | Trade URL invalido. O formato correto e: https://steamcommunity.com/tradeoffer/new/?partner=XXXXXXXX&token=YYYYYYYY | Regex nao bateu |
| 400 | Este tradelink nao pertence a sua conta Steam. | Partner ID nao corresponde ao steamId |
| 400 | Trade URL invalido. Verifique se seu inventario esta publico... | Validacao Waxpeer falhou |
Camada 1: Validacao por Regex
const TRADE_URL_REGEX =
/^https:\/\/steamcommunity\.com\/tradeoffer\/new\/\?partner=(\d+)&token=([a-zA-Z0-9_-]+)$/;
const match = trimmed.match(TRADE_URL_REGEX);
if (!match) {
throw new BadRequestException('Trade URL invalido...');
}A regex valida:
- Protocolo HTTPS obrigatorio
- Dominio exato
steamcommunity.com - Path exato
/tradeoffer/new/ - Query params
partner(digitos) etoken(alfanumerico + underscore + hifen) - Nenhum caractere extra antes ou depois (anchors
^e$)
Camada 2: Validacao de Propriedade (Steam ID)
const STEAM_ID_BASE = BigInt('76561197960265728');
const partner = match[1];
const expectedPartner = (BigInt(steamId) - STEAM_ID_BASE).toString();
if (partner !== expectedPartner) {
throw new BadRequestException('Este tradelink nao pertence a sua conta Steam.');
}Como funciona: O Partner ID da Steam e calculado como SteamID64 - 76561197960265728. Essa constante e o Steam ID base da Valve. Se o partner extraido da URL nao bater com o esperado, a URL pertence a outra conta.
Exemplo:
- Steam ID 64:
76561198439791585 - Partner esperado:
76561198439791585 - 76561197960265728 = 479525857 - URL deve conter
partner=479525857
Camada 3: Validacao via Waxpeer API
const checkResult = await this.waxpeerService.checkTradeLink(trimmed);
if (!checkResult.success) {
throw new BadRequestException(
checkResult.error ||
'Trade URL invalido. Verifique se seu inventario esta publico...',
);
}Chama o endpoint POST /v1/check-tradelink da API Waxpeer, que verifica:
- Se a Trade URL e valida e ativa na Steam
- Se o inventario do usuario esta publico
- Se o usuario nao possui restricoes de troca (VAC ban, cooldown, etc.)
Persistencia e Response
await this.userRepository.update(userId, { tradeUrl: trimmed });
return this.getUserProfileUseCase.execute(userId);Apos as 3 validacoes, a Trade URL e salva e o perfil atualizado e retornado.
DTOs
UserProfileDto
export class UserProfileDto {
@ApiProperty({ example: '123456789' })
id: string;
@ApiProperty({ example: '76561198000000000' })
steamId: string;
@ApiProperty({ example: 'PlayerName' })
username: string;
@ApiProperty({ example: 'https://avatars.steamstatic.com/...' })
avatarUrl: string;
@ApiProperty({ example: 'https://steamcommunity.com/id/...' })
profileUrl: string;
@ApiProperty({ example: 10000 })
balanceCents: string;
@ApiProperty({ example: '100.00' })
balance: string;
@ApiProperty({ example: 150 })
tickets: number;
@ApiProperty({ example: 'ACTIVE' })
status: string;
@ApiProperty({ example: false })
twoFactorEnabled: boolean;
@ApiProperty({ example: 'ABC123', nullable: true })
referralCode: string | null;
@ApiProperty({
example: 'https://steamcommunity.com/tradeoffer/new/?partner=479525857&token=vX8JQgo2',
nullable: true,
})
tradeUrl: string | null;
@ApiProperty({ example: '2025-01-28T12:00:00.000Z' })
createdAt: string;
}UserStatsDto
export class UserStatsDto {
@ApiProperty({ example: 50000 })
totalDepositedCents: string;
@ApiProperty({ example: '500.00' })
totalDeposited: string;
@ApiProperty({ example: 30000 })
totalWithdrawnCents: string;
@ApiProperty({ example: '300.00' })
totalWithdrawn: string;
@ApiProperty({ example: 100000 })
totalWageredCents: string;
@ApiProperty({ example: '1000.00' })
totalWagered: string;
@ApiProperty({ example: 20000 })
netProfitCents: string;
@ApiProperty({ example: '200.00' })
netProfit: string;
@ApiProperty({ example: 25 })
referralCount: number;
}UserBalanceDto
export class UserBalanceDto {
@ApiProperty({ example: 10000 })
balanceCents: string;
@ApiProperty({ example: '100.00' })
balance: string;
@ApiProperty({ example: 'R$ 100,00' })
balanceFormatted: string;
@ApiProperty({ example: 150 })
tickets: number;
}UpdateTradeUrlDto
export class UpdateTradeUrlDto {
@ApiProperty({
example: 'https://steamcommunity.com/tradeoffer/new/?partner=479525857&token=vX8JQgo2',
})
@IsString()
@IsNotEmpty()
tradeUrl: string;
}Controller Completo
@ApiTags('Users')
@Controller('user')
@ApiBearerAuth('session')
@ApiCookieAuth('sessionId')
export class UserController {
constructor(
private getUserProfileUseCase: GetUserProfileUseCase,
private getUserStatsUseCase: GetUserStatsUseCase,
private getUserBalanceUseCase: GetUserBalanceUseCase,
private updateTradeUrlUseCase: UpdateTradeUrlUseCase,
) {}
@Get('profile')
async getProfile(@CurrentUser() user: User): Promise<UserProfileDto> {
return this.getUserProfileUseCase.execute(user.id);
}
@Get('stats')
async getStats(@CurrentUser() user: User): Promise<UserStatsDto> {
return this.getUserStatsUseCase.execute(user.id);
}
@Get('balance')
async getBalance(@CurrentUser() user: User): Promise<UserBalanceDto> {
return this.getUserBalanceUseCase.execute(user.id);
}
@Patch('trade-url')
async updateTradeUrl(
@CurrentUser() user: User,
@Body() dto: UpdateTradeUrlDto,
): Promise<UserProfileDto> {
return this.updateTradeUrlUseCase.execute(user.id, user.steamId, dto.tradeUrl);
}
}O controller nao contem logica de negocio. Cada metodo delega para o use case correspondente, passando apenas os dados necessarios extraidos do request (userId, steamId, body).
O decorator @CurrentUser() extrai o usuario da sessao validada pelo AuthGuard. O tipo User vem do Prisma Client e contem todos os campos da tabela User.
Decorators Swagger
Todos os endpoints possuem decorators OpenAPI para documentacao automatica:
@ApiTags('Users')- agrupa na tag "Users"@ApiBearerAuth('session')- indica autenticacao por sessao@ApiCookieAuth('sessionId')- indica cookie de sessao@ApiOperation()- descricao do endpoint@ApiResponse()- schema de resposta e codigos de erro
A documentacao fica disponivel em /api/docs (Scalar UI).
Debugging
Verificar Trade URL Manualmente
// Steam ID para Partner:
const steamId = BigInt('76561198439791585');
const base = BigInt('76561197960265728');
const partner = (steamId - base).toString();
console.log('Expected partner:', partner); // 479525857
// Validar regex:
const url = 'https://steamcommunity.com/tradeoffer/new/?partner=479525857&token=vX8JQgo2';
const match = url.match(TRADE_URL_REGEX);
console.log('Regex match:', match); // [url, partner, token]Problemas Comuns
| Problema | Causa | Solucao |
|---|---|---|
| 401 em todos os endpoints | Sessao expirada ou cookie ausente | Verificar cookie sessionId |
| Trade URL rejeitado na camada 2 | Steam ID nao corresponde | Verificar calculo BigInt do partner |
| Trade URL rejeitado na camada 3 | Inventario privado ou ban | Usuario deve tornar inventario publico |
balanceCents desatualizado | Cache da tabela User defasado | Comparar com TransactionService.getUserBalance() |
netProfit negativo | Formula: deposited - withdrawn - wagered | Comportamento normal se gastou mais do que ganhou |
Regras Importantes
- Saldo no perfil e CACHE -
user.balanceCentse atualizado apos cada transacao, mas a source of truth e oTransactionService - Trade URL passa por 3 validacoes - Regex, propriedade via Steam ID, e verificacao Waxpeer
- Todos os valores monetarios sao BigInt em centavos - a conversao para exibicao e feita pelo
MoneyService - BigInt serializado como string - todos os campos BigInt sao convertidos para string antes de retornar no JSON
@CurrentUser()extrai o usuario completo da sessao, nao apenas o ID- Nenhuma logica de negocio no controller - tudo delegado para use cases
ticketsrefere-se a tickets de evento, nao a tickets de sorteioreferralCodepode ser null se o usuario nunca gerou um codigo
