Skip to content

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

CamadaArquivoResponsabilidade
Presentationsrc/presentation/controllers/user.controller.tsController HTTP
Presentationsrc/presentation/modules/user.module.tsModulo NestJS
Applicationsrc/application/use-cases/user/get-user-profile.use-case.tsConsulta de perfil
Applicationsrc/application/use-cases/user/get-user-stats.use-case.tsEstatisticas do usuario
Applicationsrc/application/use-cases/user/get-user-balance.use-case.tsConsulta de saldo
Applicationsrc/application/use-cases/user/update-trade-url.use-case.tsAtualizacao de Trade URL
Applicationsrc/application/dto/user.dto.tsDTOs e validacao
Applicationsrc/application/services/money.service.tsFormatacao monetaria

Endpoints

GET /user/profile

Retorna o perfil completo do usuario autenticado.

Autenticacao: AuthGuard (session ID via cookie)

Response (200) - UserProfileDto:

typescript
{
  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:

typescript
@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:

typescript
{
  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:

typescript
@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 - totalWagered

O 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 depositos
  • user.totalWithdrawnCents (BigInt) - acumulado de saques
  • user.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:

typescript
{
  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:

typescript
@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:

CampoExemploUso
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:

typescript
{
  tradeUrl: string  // @IsString() @IsNotEmpty()
}

Response (200): Retorna UserProfileDto (perfil atualizado).

Erros:

StatusMensagemCausa
400Trade URL invalido. O formato correto e: https://steamcommunity.com/tradeoffer/new/?partner=XXXXXXXX&token=YYYYYYYYRegex nao bateu
400Este tradelink nao pertence a sua conta Steam.Partner ID nao corresponde ao steamId
400Trade URL invalido. Verifique se seu inventario esta publico...Validacao Waxpeer falhou

Camada 1: Validacao por Regex

typescript
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) e token (alfanumerico + underscore + hifen)
  • Nenhum caractere extra antes ou depois (anchors ^ e $)

Camada 2: Validacao de Propriedade (Steam ID)

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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

typescript
export class UpdateTradeUrlDto {
  @ApiProperty({
    example: 'https://steamcommunity.com/tradeoffer/new/?partner=479525857&token=vX8JQgo2',
  })
  @IsString()
  @IsNotEmpty()
  tradeUrl: string;
}

Controller Completo

typescript
@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

typescript
// 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

ProblemaCausaSolucao
401 em todos os endpointsSessao expirada ou cookie ausenteVerificar cookie sessionId
Trade URL rejeitado na camada 2Steam ID nao correspondeVerificar calculo BigInt do partner
Trade URL rejeitado na camada 3Inventario privado ou banUsuario deve tornar inventario publico
balanceCents desatualizadoCache da tabela User defasadoComparar com TransactionService.getUserBalance()
netProfit negativoFormula: deposited - withdrawn - wageredComportamento normal se gastou mais do que ganhou

Regras Importantes

  1. Saldo no perfil e CACHE - user.balanceCents e atualizado apos cada transacao, mas a source of truth e o TransactionService
  2. Trade URL passa por 3 validacoes - Regex, propriedade via Steam ID, e verificacao Waxpeer
  3. Todos os valores monetarios sao BigInt em centavos - a conversao para exibicao e feita pelo MoneyService
  4. BigInt serializado como string - todos os campos BigInt sao convertidos para string antes de retornar no JSON
  5. @CurrentUser() extrai o usuario completo da sessao, nao apenas o ID
  6. Nenhuma logica de negocio no controller - tudo delegado para use cases
  7. tickets refere-se a tickets de evento, nao a tickets de sorteio
  8. referralCode pode ser null se o usuario nunca gerou um codigo

Documentação Técnica - ProCases