Skip to content

Raffles - Sistema de Sorteios

Sistema de sorteios onde usuarios gastam tickets (moeda virtual) para concorrer a premios (skins/itens), com multiplos premios por sorteio e resultados verificaveis via Provably Fair. Cada premio e sorteado individualmente com um roll independente, e um usuario so pode ganhar um premio por sorteio (regra one-per-user com reroll).


Conceito de Tickets (Moeda Virtual)

Tickets NAO sao saldo em BRL. Sao uma moeda virtual separada armazenada no campo User.tickets (tipo Int).

Como o usuario obtem tickets

1. Depositos (fonte principal)

Arquivo: src/application/services/deposit.service.ts

typescript
const ticketsToAdd = Math.floor(Number(deposit.amountCents) / 3000);
if (ticketsToAdd > 0) {
  const user = await this.userRepository.findById(deposit.userId);
  if (user) {
    await this.userRepository.update(deposit.userId, {
      tickets: user.tickets + ticketsToAdd,
    });
  }
}

Regra: a cada R$30,00 (3000 centavos) depositados, o usuario recebe 1 ticket. Depositos menores que R$30 nao geram tickets. O calculo usa Math.floor, ou seja, R$59,99 gera 1 ticket e R$60,00 gera 2 tickets.

2. Recompensas de Eventos (Battle Pass)

Arquivo: src/application/use-cases/event/claim-event-reward.use-case.ts

typescript
case RewardType.TICKETS:
  if (eventLevel.rewardTickets) {
    await this.userRepository.incrementTickets(userId, eventLevel.rewardTickets);
  }
  break;

Niveis do evento podem recompensar com tickets. A quantidade e definida no campo EventLevel.rewardTickets do schema Prisma.

Armazenamento no User

prisma
model User {
  tickets           Int         @default(0)
  eventTickets      Int         @default(0) @map("event_tickets")
}
  • tickets: saldo de tickets de raffle (gasto ao comprar tickets de sorteio)
  • eventTickets: saldo separado para o sistema de eventos (NAO usado em raffles)

Operacoes de tickets no UserRepository

Arquivo: src/infrastructure/database/repositories/user.repository.ts

incrementTickets:

typescript
async incrementTickets(id: bigint, amount: number): Promise<User> {
  return this.prisma.user.update({
    where: { id },
    data: {
      tickets: {
        increment: amount,
      },
    },
  });
}

decrementTickets (com validacao atomica):

typescript
async decrementTickets(id: bigint, amount: number): Promise<User> {
  const user = await this.prisma.user.findUnique({
    where: { id },
    select: { tickets: true },
  });

  if (!user || user.tickets < amount) {
    throw new Error('Insufficient tickets');
  }

  return this.prisma.user.update({
    where: {
      id,
      tickets: { gte: amount },
    },
    data: {
      tickets: {
        decrement: amount,
      },
    },
  });
}

O decrementTickets usa uma condicao WHERE tickets >= amount no update para prevenir race conditions de saldo negativo.


Modelo de Dados (Prisma Schema)

Arquivo: prisma/schema.prisma

RaffleStatus

prisma
enum RaffleStatus {
  PENDING
  ACTIVE
  DRAWING
  COMPLETED
  CANCELLED
}
StatusDescricao
PENDINGSorteio criado mas ainda nao atingiu startsAt. Nao aceita compra de tickets.
ACTIVESorteio em andamento. Aceita compra de tickets.
DRAWINGSorteio em execucao. Nao aceita mais compras. Transicao temporaria.
COMPLETEDSorteio finalizado. Todos os premios entregues.
CANCELLEDSorteio cancelado (nenhum ticket vendido quando o draw foi acionado).

Transicoes de status

PENDING -> ACTIVE       (startsAt atingido via activateScheduledRaffles)
ACTIVE -> DRAWING       (draw iniciado via DrawRaffleUseCase)
ACTIVE -> CANCELLED     (draw iniciado mas soldTickets == 0)
DRAWING -> COMPLETED    (todos os premios sorteados com sucesso)

Raffle

prisma
model Raffle {
  id                BigInt         @id
  ticketPrice       Int            @default(1) @map("ticket_price")
  maxTickets        Int            @map("max_tickets")
  minTicketsPerUser Int?           @map("min_tickets_per_user")
  maxTicketsPerUser Int?           @map("max_tickets_per_user")
  status            RaffleStatus   @default(PENDING)
  startsAt          DateTime       @map("starts_at")
  endsAt            DateTime       @map("ends_at")
  drawnAt           DateTime?      @map("drawn_at")
  createdAt         DateTime       @default(now()) @map("created_at")

  prizes          RafflePrize[]
  tickets         RaffleTicket[]

  @@map("raffles")
  @@index([status, endsAt])
  @@index([startsAt])
}
CampoTipoDescricao
idBigIntSnowflake ID gerado pelo SnowflakeService
ticketPriceIntCusto em tickets por unidade comprada. Default 1. Ex: se ticketPrice=2 e o usuario compra 5, gasta 10 tickets.
maxTicketsIntTotal maximo de tickets (numeros) que podem ser vendidos no sorteio.
minTicketsPerUserInt?Minimo de tickets na primeira compra. Validado apenas quando userTicketCount === 0. Opcional.
maxTicketsPerUserInt?Maximo de tickets por usuario no sorteio inteiro. Opcional.
statusRaffleStatusStatus atual. Comeca como PENDING.
startsAtDateTimeQuando o sorteio fica ativo para compras.
endsAtDateTimeDeadline. Compras apos essa data sao rejeitadas.
drawnAtDateTime?Momento em que o sorteio foi executado. Preenchido apos COMPLETED.

RafflePrize

prisma
model RafflePrize {
  id                  BigInt    @id
  raffleId            BigInt    @map("raffle_id")
  itemId              BigInt    @map("item_id")
  valueCents          BigInt    @map("value_cents")
  valueCentsBrl       BigInt    @map("value_cents_brl")
  displayOrder        Int       @default(0) @map("display_order")
  winnerId            BigInt?   @map("winner_id")
  winningTicketNumber Int?      @map("winning_ticket_number")
  serverSeed          String?   @map("server_seed")
  serverSeedHash      String?   @map("server_seed_hash")
  clientSeed          String?   @map("client_seed")
  nonce               Int?
  roll                Int?
  drawnAt             DateTime? @map("drawn_at")
  createdAt           DateTime  @default(now()) @map("created_at")

  raffle          Raffle   @relation(fields: [raffleId], references: [id], onDelete: Cascade)
  item            Item     @relation(fields: [itemId], references: [id])
  winner          User?    @relation("RafflePrizeWinner", fields: [winnerId], references: [id])

  @@map("raffle_prizes")
  @@index([raffleId])
  @@index([winnerId])
}
CampoTipoDescricao
idBigIntSnowflake ID
raffleIdBigIntFK para o sorteio pai
itemIdBigIntFK para o item que e o premio
valueCentsBigIntValor do item em centavos USD
valueCentsBrlBigIntValor do item em centavos BRL (usado para exibicao)
displayOrderIntOrdem de exibicao e de sorteio. Premios sao sorteados em ordem crescente de displayOrder.
winnerIdBigInt?ID do usuario vencedor. Preenchido apos o draw.
winningTicketNumberInt?Numero do ticket vencedor. Preenchido apos o draw.
serverSeedString?Server seed usado neste prize especifico (revelado apos draw).
serverSeedHashString?Hash do server seed (visivel antes do draw para verificacao).
clientSeedString?Client seed usado (mesmo para todos os prizes do sorteio).
nonceInt?Nonce usado: displayOrder + 1
rollInt?Resultado do roll Provably Fair.
drawnAtDateTime?Momento em que este premio foi sorteado.

Cada prize tem seus PROPRIOS dados de Provably Fair (serverSeed, serverSeedHash, clientSeed, nonce, roll). O serverSeed e gerado individualmente por prize no momento do draw. O clientSeed e o mesmo para todos os prizes (passado como parametro do draw).

RaffleTicket

prisma
model RaffleTicket {
  id              BigInt   @id
  raffleId        BigInt   @map("raffle_id")
  userId          BigInt   @map("user_id")
  ticketNumber    Int      @map("ticket_number")
  purchasedAt     DateTime @default(now()) @map("purchased_at")

  raffle          Raffle   @relation(fields: [raffleId], references: [id], onDelete: Cascade)
  user            User     @relation(fields: [userId], references: [id])

  @@unique([raffleId, ticketNumber])
  @@map("raffle_tickets")
  @@index([raffleId])
  @@index([userId])
  @@index([raffleId, userId])
}
CampoTipoDescricao
idBigIntSnowflake ID
raffleIdBigIntFK para o sorteio
userIdBigIntFK para o usuario que comprou
ticketNumberIntNumero sequencial do ticket. Comeca em 0. Unique por raffle.
purchasedAtDateTimeMomento da compra

O constraint @@unique([raffleId, ticketNumber]) garante que nao existam tickets duplicados dentro do mesmo sorteio.


Fluxo de Compra de Tickets

Arquivo: src/application/use-cases/raffle/buy-raffle-tickets.use-case.ts

Codigo completo do use case

typescript
@Injectable()
export class BuyRaffleTicketsUseCase {
  constructor(
    @Inject('IRaffleRepository')
    private readonly raffleRepository: IRaffleRepository,
    @Inject('IUserRepository')
    private readonly userRepository: IUserRepository,
    private readonly lockService: LockService,
    private readonly prisma: PrismaService,
    private readonly webSocketGateway: WebSocketGatewayService,
    private readonly drawRaffleUseCase: DrawRaffleUseCase,
    private readonly auditService: AuditService,
    private readonly transactionService: TransactionService,
  ) {}

  async execute(userId: bigint, raffleId: bigint, quantity: number) {
    if (quantity < 1 || quantity > RAFFLE_LIMITS.MAX_TICKETS_PER_PURCHASE) {
      throw new BadRequestException(
        `Compre entre 1 e ${RAFFLE_LIMITS.MAX_TICKETS_PER_PURCHASE} tickets por vez`,
      );
    }

    const lockResult = await this.lockService.withLock(`raffle:buy:${raffleId}`, async () => {
      return this.lockService.withUserBalanceLock(userId, async () => {
        const raffle = await this.raffleRepository.findById(raffleId);
        if (!raffle) {
          throw new NotFoundException('Sorteio nao encontrado');
        }

        if (raffle.status !== RaffleStatus.ACTIVE) {
          throw new BadRequestException('Sorteio nao esta ativo');
        }

        if (raffle.endsAt < new Date()) {
          throw new BadRequestException('Sorteio encerrado');
        }

        const user = await this.userRepository.findById(userId);
        if (!user) {
          throw new NotFoundException('Usuario nao encontrado');
        }

        const totalTicketsSold = await this.raffleRepository.getTotalTicketsSold(raffleId);
        if (totalTicketsSold + quantity > raffle.maxTickets) {
          throw new BadRequestException('Nao ha tickets suficientes disponiveis');
        }

        const userTicketCount = await this.raffleRepository.getUserTicketCount(raffleId, userId);

        if (
          raffle.minTicketsPerUser &&
          userTicketCount === 0 &&
          quantity < raffle.minTicketsPerUser
        ) {
          throw new BadRequestException(
            `Minimo de ${raffle.minTicketsPerUser} tickets para participar deste sorteio`,
          );
        }

        if (raffle.maxTicketsPerUser && userTicketCount + quantity > raffle.maxTicketsPerUser) {
          throw new BadRequestException(
            `Maximo de ${raffle.maxTicketsPerUser} tickets por pessoa neste sorteio`,
          );
        }

        const totalCost = quantity * raffle.ticketPrice;
        if (user.tickets < totalCost) {
          throw new BadRequestException('Tickets insuficientes');
        }

        const result = await this.prisma.$transaction(async () => {
          await this.userRepository.decrementTickets(userId, totalCost);

          const freshTotalSold = await this.raffleRepository.getTotalTicketsSold(raffleId);
          const tickets = [];
          for (let i = 0; i < quantity; i++) {
            const ticketNumber = freshTotalSold + i;
            const ticket = await this.raffleRepository.addTicket({
              raffleId,
              userId,
              ticketNumber,
            });
            tickets.push(ticket);
          }

          return {
            success: true,
            tickets,
            remainingTickets: user.tickets - totalCost,
            totalTicketsSold: freshTotalSold + quantity,
            maxTickets: raffle.maxTickets,
          };
        });

        this.webSocketGateway.emitRaffleTicketPurchased(raffleId, {
          userId: userId.toString(),
          username: user.username,
          avatarUrl: user.avatarUrl || '',
          quantity,
          totalTicketsSold: result.totalTicketsSold,
          maxTickets: raffle.maxTickets,
        });

        // Registro contabil: nao move saldo BRL, apenas tickets sao gastos
        await this.transactionService.logOnly(
          userId,
          BigInt(0),
          `Sorteio: Comprou ${quantity} ticket(s) no sorteio #${raffleId}`,
          {
            action: 'RAFFLE_TICKET_PURCHASE',
            raffleId: raffleId.toString(),
            quantity,
            totalCost,
            ticketPrice: raffle.ticketPrice,
            ticketsSpent: totalCost,
          },
        );

        await this.auditService.logUserAction(
          userId,
          'RAFFLE_TICKETS_PURCHASED',
          'Raffle',
          raffleId.toString(),
          {
            quantity,
            totalCost,
            totalTicketsSold: result.totalTicketsSold,
            remainingTickets: result.remainingTickets,
          },
        );

        return result;
      });
    }, 10000);

    if (lockResult.totalTicketsSold >= lockResult.maxTickets) {
      const clientSeed = `auto-draw-${Date.now()}`;
      this.drawRaffleUseCase.execute(raffleId, clientSeed, 'AUTO_DRAW').catch((err) => {
        console.error(`[Raffle] Auto-draw failed for raffle ${raffleId}:`, err);
      });
    }

    return lockResult;
  }
}

Fluxo passo a passo

  1. Validacao de quantidade: quantity deve estar entre 1 e RAFFLE_LIMITS.MAX_TICKETS_PER_PURCHASE (500) — validacao do DTO com @IsInt() @Min(1) @Max(500) e validacao adicional no use case. Feita ANTES de qualquer lock.

  2. Locks distribuidos aninhados: primeiro lockService.withLock("raffle:buy:{raffleId}", ..., 10000) (serializa compras concorrentes no MESMO sorteio), e dentro dele withUserBalanceLock(userId) (serializa compras do mesmo usuario). Todas as leituras e writes abaixo acontecem dentro desses dois locks.

  3. Busca do raffle: raffleRepository.findById(raffleId). Retorna 404 se nao encontrado.

  4. Validacao de status: O raffle DEVE ter status ACTIVE. Qualquer outro status rejeita com 400.

  5. Validacao de prazo: raffle.endsAt deve ser no futuro. Se ja passou, rejeita com 400.

  6. Busca do usuario: userRepository.findById(userId). Retorna 404 se nao encontrado.

  7. Validacao de disponibilidade: getTotalTicketsSold(raffleId) conta tickets no banco. Se totalTicketsSold + quantity > maxTickets, rejeita.

  8. Validacao de minTicketsPerUser: Se raffle.minTicketsPerUser esta definido E o usuario ainda nao tem tickets (userTicketCount === 0) E quantity < minTicketsPerUser, rejeita. Essa validacao so se aplica na primeira compra do usuario.

  9. Validacao de maxTicketsPerUser: Se raffle.maxTicketsPerUser esta definido E userTicketCount + quantity > maxTicketsPerUser, rejeita. Aplica-se em todas as compras.

  10. Calculo de custo: totalCost = quantity * raffle.ticketPrice. O custo e em tickets (moeda virtual), NAO em centavos BRL.

  11. Validacao de saldo de tickets: user.tickets < totalCost rejeita com "Tickets insuficientes".

  12. Transacao atomica (prisma.$transaction):

    • Debita tickets do usuario via userRepository.decrementTickets(userId, totalCost)
    • Re-le a contagem dentro da transacao (freshTotalSold = getTotalTicketsSold(raffleId)) e cria os tickets com numeracao sequencial: ticketNumber = freshTotalSold + i (comecando em 0)
    • Retorna resultado com tickets criados, saldo restante e contagem atualizada
  13. Broadcast WebSocket: Emite raffle:ticket_purchased para todos os clientes conectados.

  14. Registro contabil (logOnly) + auditoria: transactionService.logOnly(userId, BigInt(0), ..., { action: 'RAFFLE_TICKET_PURCHASE', ... }) registra a compra SEM mover saldo BRL (apenas tickets sao gastos), e auditService.logUserAction(userId, 'RAFFLE_TICKETS_PURCHASED', ...) audita a operacao. Ambos dentro dos locks.

  15. Auto-draw (apos liberar os locks): Se lockResult.totalTicketsSold >= lockResult.maxTickets, dispara o sorteio automaticamente via drawRaffleUseCase.execute(raffleId, clientSeed, 'AUTO_DRAW') com clientSeed = "auto-draw-{timestamp}". Execucao fire-and-forget (.catch() apenas loga erro). NAO bloqueia a resposta da compra.

Numeracao dos tickets

Os tickets sao numerados sequencialmente comecando em 0:

Primeira compra (3 tickets): ticketNumber = 0, 1, 2
Segunda compra (2 tickets):  ticketNumber = 3, 4
Terceira compra (1 ticket):  ticketNumber = 5

A numeracao e baseada na contagem re-lida dentro da transacao (freshTotalSold), garantida pelos locks distribuidos (raffle:buy:{raffleId} + user:balance:{userId}).


Fluxo de Sorteio (Draw)

Arquivo: src/application/use-cases/raffle/draw-raffle.use-case.ts

Constantes

typescript
const ANIMATION_DURATION_MS = 8000;

Cada premio e animado no frontend por 8 segundos. O campo animationDelayMs de cada prize result e calculado como sequenceIndex * 8000.

Codigo completo do use case

typescript
@Injectable()
export class DrawRaffleUseCase {
  constructor(
    @Inject('IRaffleRepository')
    private readonly raffleRepository: IRaffleRepository,
    @Inject('IUserInventoryRepository')
    private readonly inventoryRepository: IUserInventoryRepository,
    @Inject('IUserRepository')
    private readonly userRepository: IUserRepository,
    private readonly provablyFairService: ProvablyFairService,
    private readonly notificationService: NotificationService,
    private readonly webSocketGateway: WebSocketGatewayService,
    private readonly lockService: LockService,
    private readonly prisma: PrismaService,
    private readonly auditService: AuditService,
    private readonly transactionService: TransactionService,
  ) {}

  async execute(raffleId: bigint, clientSeed: string, triggeredByUserId: bigint | 'AUTO_DRAW') {
    return this.lockService.withLock(
      `raffle:draw:${raffleId}`,
      async () => {
        const raffle = await this.raffleRepository.findById(raffleId);
        if (!raffle) {
          throw new NotFoundException('Sorteio nao encontrado');
        }

        if (raffle.status !== RaffleStatus.ACTIVE) {
          throw new BadRequestException('Sorteio nao esta ativo');
        }

        const allTickets = await this.raffleRepository.getTicketsByRaffleId(raffleId);
        if (allTickets.length === 0) {
          await this.raffleRepository.updateStatus(raffleId, RaffleStatus.CANCELLED);
          throw new BadRequestException('Nenhum ticket vendido, sorteio cancelado');
        }

        await this.raffleRepository.updateStatus(raffleId, RaffleStatus.DRAWING);
        this.webSocketGateway.emitRaffleDrawing(raffleId);

        const prizes = (raffle.prizes || []).sort(
          (a: any, b: any) => a.displayOrder - b.displayOrder,
        );

        const excludedUserIds = new Set<string>();
        const prizeResults: { /* ... */ }[] = [];

        for (let i = 0; i < prizes.length; i++) {
          const prize = prizes[i];

          let eligibleTickets = allTickets.filter(
            (t: any) => !excludedUserIds.has(t.userId.toString()),
          );
          if (eligibleTickets.length === 0) {
            eligibleTickets = allTickets;
          }

          const serverSeed = this.provablyFairService.generateServerSeed();
          const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
          const nonce = prize.displayOrder + 1;

          const roll = this.provablyFairService.calculateRoll(
            serverSeed,
            clientSeed,
            nonce,
            eligibleTickets.length,
          );

          const winningIndex = roll % eligibleTickets.length;
          const winningTicket = eligibleTickets[winningIndex];

          await this.prisma.$transaction(async () => {
            await this.raffleRepository.setPrizeWinner(
              prize.id,
              winningTicket.userId,
              winningTicket.ticketNumber,
              serverSeed,
              serverSeedHash,
              clientSeed,
              nonce,
              roll,
            );

            await this.inventoryRepository.create({
              userId: winningTicket.userId,
              itemId: prize.itemId,
              acquiredFrom: 'Sorteio',
              acquiredValueCents: prize.valueCentsBrl,
            });
          });

          excludedUserIds.add(winningTicket.userId.toString());

          const winner = await this.userRepository.findById(winningTicket.userId);

          await this.notificationService.create({
            userId: winningTicket.userId,
            type: NotificationType.SYSTEM,
            title: 'Voce ganhou no sorteio!',
            message: `Parabens! Voce ganhou: ${prize.item?.name || 'Item'}`,
            metadata: {
              raffleId: raffleId.toString(),
              prizeId: prize.id.toString(),
              winningTicketNumber: winningTicket.ticketNumber,
            },
          });

          await this.transactionService.logOnly(
            winningTicket.userId,
            prize.valueCentsBrl,
            `Sorteio: Ganhou ${prize.item?.name || 'Item'} no sorteio #${raffleId}`,
            {
              action: 'RAFFLE_PRIZE_WON',
              raffleId: raffleId.toString(),
              wonItemId: prize.itemId.toString(),
              wonItemName: prize.item?.name || '',
              wonItemRarity: prize.item?.rarity || '',
              wonItemValueCents: prize.valueCentsBrl.toString(),
              winningTicketNumber: winningTicket.ticketNumber,
            },
          );

          prizeResults.push({
            prizeId: prize.id.toString(),
            displayOrder: prize.displayOrder,
            itemId: prize.itemId.toString(),
            itemName: prize.item?.name || '',
            itemImageUrl: prize.item?.imageUrl || '',
            itemRarity: prize.item?.rarity || '',
            valueCentsBrl: prize.valueCentsBrl.toString(),
            winnerId: winningTicket.userId.toString(),
            winnerUsername: winner?.username || '',
            winnerAvatarUrl: winner?.avatarUrl || '',
            winningTicketNumber: winningTicket.ticketNumber,
            serverSeedHash,
            sequenceIndex: i,
            animationDelayMs: i * ANIMATION_DURATION_MS,
          });
        }

        await this.prisma.raffle.update({
          where: { id: raffleId },
          data: {
            status: RaffleStatus.COMPLETED,
            drawnAt: new Date(),
          },
        });

        this.webSocketGateway.emitRaffleResults(raffleId, prizeResults);
        this.webSocketGateway.emitRaffleCompleted(raffleId);

        const auditMetadata = {
          triggeredBy: triggeredByUserId === 'AUTO_DRAW' ? 'AUTO_DRAW' : triggeredByUserId.toString(),
          prizesCount: prizes.length,
          winnersCount: prizeResults.length,
          ticketsSold: allTickets.length,
          maxTickets: raffle.maxTickets,
        };

        if (triggeredByUserId === 'AUTO_DRAW') {
          await this.auditService.logSystemAction(
            'RAFFLE_DRAWN', 'Raffle', raffleId.toString(), auditMetadata,
          );
        } else {
          await this.auditService.logUserAction(
            triggeredByUserId, 'RAFFLE_DRAWN', 'Raffle', raffleId.toString(), auditMetadata,
          );
        }

        return this.raffleRepository.findById(raffleId);
      },
      30000,
    );
  }
}

Fluxo passo a passo

  1. Lock distribuido: lockService.withLock("raffle:draw:{raffleId}", ..., 30000) adquire lock com TTL de 30 segundos. Previne draws concorrentes.

  2. Validacao: Status DEVE ser ACTIVE. Se nao, rejeita com 400.

  3. Busca de todos os tickets: getTicketsByRaffleId(raffleId) retorna todos os tickets ordenados por ticketNumber ASC.

  4. Cancelamento automatico: Se allTickets.length === 0, muda status para CANCELLED e lanca excecao.

  5. Transicao para DRAWING: Atualiza status e emite evento WebSocket raffle:drawing.

  6. Ordenacao de premios: Premios sao ordenados por displayOrder ASC. O premio com menor displayOrder e sorteado primeiro.

  7. Loop de sorteio por premio: Para cada prize, executa:

Algoritmo de selecao de vencedor (por premio)

Para cada prize (em ordem de displayOrder):
  1. Filtrar eligibleTickets = tickets cujo userId NAO esta em excludedUserIds
  2. Se eligibleTickets esta vazio (todos os participantes ja ganharam):
     -> Reset: eligibleTickets = allTickets (todos voltam a ser elegiveis)
  3. Gerar serverSeed = randomBytes(32).toString('hex')
  4. Calcular serverSeedHash = HMAC-SHA256(serverSeed)
  5. nonce = prize.displayOrder + 1
  6. roll = provablyFairService.calculateRoll(serverSeed, clientSeed, nonce, eligibleTickets.length)
  7. winningIndex = roll % eligibleTickets.length
  8. winningTicket = eligibleTickets[winningIndex]
  9. Salvar dados do prize winner (transacao atomica)
  10. Adicionar item ao inventario do vencedor (acquiredFrom: 'Sorteio', acquiredValueCents: prize.valueCentsBrl)
  11. Adicionar userId ao excludedUserIds (one-per-user)
  12. Criar notificacao para o vencedor
  13. Registrar logOnly (RAFFLE_PRIZE_WON) sem mover saldo

Regra one-per-user

Cada usuario so pode ganhar UM premio por sorteio. Apos ganhar, seu userId e adicionado ao excludedUserIds (Set). Seus tickets sao excluidos dos tickets elegiveis para os premios seguintes.

Reset quando todos ja ganharam: Se, ao filtrar, nenhum ticket elegivel resta (ex: 3 participantes e 4 premios), o excludedUserIds e efetivamente ignorado e eligibleTickets = allTickets, permitindo que usuarios ganhem mais de um premio. Este e o unico caso em que a regra one-per-user nao se aplica.

Provably Fair no Draw

O calculateRoll no contexto do raffle usa max = eligibleTickets.length (NAO o default de 10.000.000):

typescript
const roll = this.provablyFairService.calculateRoll(
  serverSeed,
  clientSeed,
  nonce,
  eligibleTickets.length,  // max = quantidade de tickets elegiveis
);

Arquivo: src/application/services/provably-fair.service.ts

typescript
calculateRoll(
  serverSeed: string,
  clientSeed: string,
  nonce: number,
  max: number = 10000000,
): number {
  const hmac = createHmac('sha256', serverSeed);
  hmac.update(`${clientSeed}:${nonce}`);
  const hash = hmac.digest('hex');

  const hashPrefix = hash.substring(0, 13);
  const decimal = parseInt(hashPrefix, 16);

  const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;

  let result = decimal;
  if (result >= maxSafeValue) {
    const hmac2 = createHmac('sha256', serverSeed);
    hmac2.update(`${clientSeed}:${nonce}:retry`);
    const hash2 = hmac2.digest('hex');
    result = parseInt(hash2.substring(0, 13), 16);
  }

  const roll = (result % max) + 1;
  return roll;
}

O roll retorna um valor entre 1 e max (inclusive). O winningIndex e calculado como roll % eligibleTickets.length, resultando em um indice entre 0 e eligibleTickets.length - 1.

Cada prize tem:

  • Seu proprio serverSeed (gerado na hora)
  • Seu proprio serverSeedHash
  • O mesmo clientSeed (compartilhado entre todos os prizes)
  • nonce = displayOrder + 1

Entrega de premios

Todos os premios sao itens (skins). A entrega e feita adicionando o item ao inventario do usuario:

typescript
await this.inventoryRepository.create({
  userId: winningTicket.userId,
  itemId: prize.itemId,
  acquiredFrom: 'Sorteio',
  acquiredValueCents: prize.valueCentsBrl,
});

NAO ha movimentacao de saldo BRL: o premio e sempre um item do tipo Item referenciado por prize.itemId. Uma entrada logOnly (RAFFLE_PRIZE_WON) registra o valor ganho (prize.valueCentsBrl) sem alterar o saldo.

Notificacao ao vencedor

Para cada premio sorteado, uma notificacao e criada:

typescript
await this.notificationService.create({
  userId: winningTicket.userId,
  type: NotificationType.SYSTEM,
  title: 'Voce ganhou no sorteio!',
  message: `Parabens! Voce ganhou: ${prize.item?.name || 'Item'}`,
  metadata: {
    raffleId: raffleId.toString(),
    prizeId: prize.id.toString(),
    winningTicketNumber: winningTicket.ticketNumber,
  },
});

A notificacao usa o tipo NotificationType.SYSTEM e inclui metadata com raffleId, prizeId e winningTicketNumber para rastreabilidade.

Timing de animacao

Cada prize result inclui campos para controle de animacao no frontend:

typescript
sequenceIndex: i,                          // 0, 1, 2, 3...
animationDelayMs: i * ANIMATION_DURATION_MS, // 0, 8000, 16000, 24000...

ANIMATION_DURATION_MS = 8000 (8 segundos por premio).

Para um sorteio com 3 premios, a timeline no frontend e:

  • Premio 1: inicia em 0ms, termina em 8000ms
  • Premio 2: inicia em 8000ms, termina em 16000ms
  • Premio 3: inicia em 16000ms, termina em 24000ms
  • Duracao total: prizes.length * 8000ms

Finalizacao

Apos sortear todos os premios:

  1. Atualiza o raffle para status: COMPLETED e drawnAt: new Date()
  2. Emite raffle:results com todos os prizeResults
  3. Emite raffle:completed
  4. Retorna o raffle atualizado do banco

Repositorio

Arquivo: src/infrastructure/database/repositories/raffle.repository.ts

PRIZES_INCLUDE (reutilizavel)

typescript
const PRIZES_INCLUDE = {
  prizes: {
    include: {
      item: true,
      winner: {
        select: {
          id: true,
          username: true,
          avatarUrl: true,
        },
      },
    },
    orderBy: { displayOrder: 'asc' as const },
  },
};

Todas as queries que retornam raffles incluem os prizes com item data e winner data, ordenados por displayOrder ASC.

Metodos do repositorio

MetodoDescricao
create(data)Cria raffle com prizes. IDs gerados via Snowflake. Status inicial PENDING.
findById(id)Busca raffle com prizes, item, winner e tickets (com user data).
findActive()Busca raffles com status ACTIVE e endsAt > now(). Inclui _count.tickets. Ordenado por endsAt ASC.
findCompleted(limit)Busca raffles COMPLETED. Ordenado por drawnAt DESC. Default limit 10.
updateStatus(id, status)Atualiza apenas o status.
setPrizeWinner(prizeId, winnerId, ...)Atualiza o RafflePrize com dados do vencedor e Provably Fair.
getTicketsByRaffleId(raffleId)Retorna todos os tickets ordenados por ticketNumber ASC.
addTicket(data)Cria ticket com Snowflake ID.
getUserTickets(raffleId, userId)Retorna tickets do usuario no raffle, ordenados por ticketNumber ASC.
getUserTicketCount(raffleId, userId)Conta tickets do usuario no raffle.
getTotalTicketsSold(raffleId)Conta total de tickets vendidos no raffle.
activateScheduledRaffles()Muda status de PENDING para ACTIVE onde startsAt <= now(). Retorna count.
completeExpiredRaffles()Busca raffles ACTIVE com endsAt <= now(). Retorna para processamento.

Interface do repositorio

Arquivo: src/domain/repositories/raffle.repository.interface.ts

typescript
export interface IRaffleRepository {
  create(data: {
    ticketPrice: number;
    maxTickets: number;
    minTicketsPerUser?: number;
    maxTicketsPerUser?: number;
    startsAt: Date;
    endsAt: Date;
    prizes: Array<{
      itemId: bigint;
      valueCents: bigint;
      valueCentsBrl: bigint;
      displayOrder: number;
    }>;
  }): Promise<any>;

  findById(id: bigint): Promise<any>;
  findActive(): Promise<any[]>;
  findCompleted(limit?: number): Promise<any[]>;
  updateStatus(id: bigint, status: RaffleStatus): Promise<any>;

  setPrizeWinner(
    prizeId: bigint,
    winnerId: bigint,
    winningTicketNumber: number,
    serverSeed: string,
    serverSeedHash: string,
    clientSeed: string,
    nonce: number,
    roll: number,
  ): Promise<any>;

  getTicketsByRaffleId(raffleId: bigint): Promise<any[]>;
  addTicket(data: { raffleId: bigint; userId: bigint; ticketNumber: number }): Promise<any>;
  getUserTickets(raffleId: bigint, userId: bigint): Promise<any[]>;
  getUserTicketCount(raffleId: bigint, userId: bigint): Promise<number>;
  getTotalTicketsSold(raffleId: bigint): Promise<number>;
  activateScheduledRaffles(): Promise<number>;
  completeExpiredRaffles(): Promise<any[]>;
}

Use Cases de Leitura

GetActiveRafflesUseCase

Arquivo: src/application/use-cases/raffle/get-active-raffles.use-case.ts

typescript
@Injectable()
export class GetActiveRafflesUseCase {
  constructor(
    @Inject('IRaffleRepository')
    private readonly raffleRepository: IRaffleRepository,
  ) {}

  async execute() {
    return this.raffleRepository.findActive();
  }
}

Retorna raffles com status ACTIVE e endsAt > now(), ordenados por endsAt ASC.

GetCompletedRafflesUseCase

Arquivo: src/application/use-cases/raffle/get-completed-raffles.use-case.ts

typescript
@Injectable()
export class GetCompletedRafflesUseCase {
  constructor(
    @Inject('IRaffleRepository')
    private readonly raffleRepository: IRaffleRepository,
  ) {}

  async execute(limit: number = 10) {
    return this.raffleRepository.findCompleted(limit);
  }
}

Retorna raffles com status COMPLETED, ordenados por drawnAt DESC, com limite configuravel (default 10).

GetRaffleDetailsUseCase

Arquivo: src/application/use-cases/raffle/get-raffle-details.use-case.ts

typescript
@Injectable()
export class GetRaffleDetailsUseCase {
  constructor(
    @Inject('IRaffleRepository')
    private readonly raffleRepository: IRaffleRepository,
  ) {}

  async execute(raffleId: bigint, userId?: bigint) {
    const raffle = await this.raffleRepository.findById(raffleId);
    if (!raffle) {
      throw new NotFoundException('Raffle not found');
    }

    const totalTicketsSold = await this.raffleRepository.getTotalTicketsSold(raffleId);

    let userTickets = [];
    let userTicketCount = 0;
    if (userId) {
      userTickets = await this.raffleRepository.getUserTickets(raffleId, userId);
      userTicketCount = userTickets.length;
    }

    return {
      ...raffle,
      totalTicketsSold,
      userTickets,
      userTicketCount,
      availableTickets: raffle.maxTickets - totalTicketsSold,
    };
  }
}

Aceita userId opcional (via OptionalAuthGuard). Se o usuario esta autenticado, retorna seus tickets e contagem. Calcula availableTickets como maxTickets - totalTicketsSold.


Endpoints da API

Arquivo: src/presentation/controllers/raffle.controller.ts

DTOs de entrada

typescript
class BuyTicketsDto {
  @IsInt()
  @Min(1)
  @Max(500)
  quantity: number;
}

class DrawRaffleDto {
  @IsString()
  clientSeed: string;
}

Metodo auxiliar formatRaffle

Todas as respostas passam pelo formatRaffle() que normaliza os dados:

typescript
private formatRaffle(raffle: any) {
  const prizes = (raffle.prizes || []).map((prize: any) => ({
    id: prize.id.toString(),
    itemId: prize.itemId.toString(),
    name: prize.item?.name || '',
    imageUrl: prize.item?.imageUrl || '',
    rarity: prize.item?.rarity || '',
    valueCentsBrl: prize.valueCentsBrl.toString(),
    valueFormatted: this.moneyService.format(prize.valueCentsBrl),
    displayOrder: prize.displayOrder,
    winner: prize.winner
      ? {
          id: prize.winner.id.toString(),
          username: prize.winner.username,
          avatarUrl: prize.winner.avatarUrl,
        }
      : null,
    winningTicketNumber: prize.winningTicketNumber ?? null,
    serverSeedHash: prize.serverSeedHash ?? null,
  }));

  const totalPrizeCentsBrl = (raffle.prizes || []).reduce(
    (sum: bigint, p: any) => sum + BigInt(p.valueCentsBrl),
    BigInt(0),
  );

  return {
    id: raffle.id.toString(),
    ticketPrice: raffle.ticketPrice,
    maxTickets: raffle.maxTickets,
    minTicketsPerUser: raffle.minTicketsPerUser,
    maxTicketsPerUser: raffle.maxTicketsPerUser,
    status: raffle.status,
    startsAt: raffle.startsAt,
    endsAt: raffle.endsAt,
    drawnAt: raffle.drawnAt,
    createdAt: raffle.createdAt,
    prizes,
    totalPrizeCentsBrl: totalPrizeCentsBrl.toString(),
    totalPrizeFormatted: this.moneyService.format(totalPrizeCentsBrl),
    ticketsSold: raffle._count?.tickets ?? raffle.tickets?.length ?? 0,
  };
}

O MoneyService.format() formata centavos BRL para string brasileira (ex: 70765n -> "R$ 707,65").


GET /raffle/active

Autenticacao: Publica (@Public())

Descricao: Lista todos os sorteios ativos.

Response (200):

json
[
  {
    "id": "1234567890123",
    "ticketPrice": 1,
    "maxTickets": 500,
    "minTicketsPerUser": null,
    "maxTicketsPerUser": 50,
    "status": "ACTIVE",
    "startsAt": "2026-02-10T00:00:00.000Z",
    "endsAt": "2026-02-15T00:00:00.000Z",
    "drawnAt": null,
    "createdAt": "2026-02-09T20:00:00.000Z",
    "prizes": [
      {
        "id": "9876543210123",
        "itemId": "5555555555555",
        "name": "AWP | Dragon Lore",
        "imageUrl": "https://...",
        "rarity": "EXTRAORDINARY",
        "valueCentsBrl": "70765",
        "valueFormatted": "R$ 707,65",
        "displayOrder": 0,
        "winner": null,
        "winningTicketNumber": null,
        "serverSeedHash": null
      }
    ],
    "totalPrizeCentsBrl": "70765",
    "totalPrizeFormatted": "R$ 707,65",
    "ticketsSold": 123
  }
]

GET /raffle/completed

Autenticacao: Publica (@Public())

Query params:

ParamTipoDefaultDescricao
limitstring (convertido para int)10Quantidade maxima de resultados

Response (200): Mesmo formato que GET /raffle/active, porem com premios que contem dados de vencedores preenchidos.

json
[
  {
    "id": "1234567890123",
    "status": "COMPLETED",
    "drawnAt": "2026-02-12T15:30:00.000Z",
    "prizes": [
      {
        "id": "9876543210123",
        "name": "AWP | Dragon Lore",
        "winner": {
          "id": "1111111111111",
          "username": "PlayerOne",
          "avatarUrl": "https://..."
        },
        "winningTicketNumber": 42,
        "serverSeedHash": "abc123..."
      }
    ]
  }
]

GET /raffle/:id

Autenticacao: Publica com autenticacao opcional (@UseGuards(OptionalAuthGuard) + @Public())

Params:

ParamTipoDescricao
idstring (convertido para BigInt)ID do raffle

Response (200):

json
{
  "id": "1234567890123",
  "ticketPrice": 1,
  "maxTickets": 500,
  "minTicketsPerUser": null,
  "maxTicketsPerUser": 50,
  "status": "ACTIVE",
  "startsAt": "2026-02-10T00:00:00.000Z",
  "endsAt": "2026-02-15T00:00:00.000Z",
  "drawnAt": null,
  "createdAt": "2026-02-09T20:00:00.000Z",
  "prizes": [],
  "totalPrizeCentsBrl": "70765",
  "totalPrizeFormatted": "R$ 707,65",
  "ticketsSold": 123,
  "totalTicketsSold": 123,
  "availableTickets": 377,
  "userTicketCount": 5,
  "userTickets": [
    { "ticketNumber": 10 },
    { "ticketNumber": 11 },
    { "ticketNumber": 12 },
    { "ticketNumber": 13 },
    { "ticketNumber": 14 }
  ],
  "participants": [
    {
      "userId": "1111111111111",
      "username": "PlayerOne",
      "avatarUrl": "https://...",
      "ticketNumber": 0
    },
    {
      "userId": "2222222222222",
      "username": "PlayerTwo",
      "avatarUrl": "https://...",
      "ticketNumber": 1
    }
  ]
}

Campos exclusivos deste endpoint:

  • totalTicketsSold: contagem real de tickets vendidos
  • availableTickets: maxTickets - totalTicketsSold
  • userTicketCount: quantidade de tickets do usuario autenticado (0 se nao autenticado)
  • userTickets: lista de tickets do usuario com ticketNumber
  • participants: lista de TODOS os tickets com dados do usuario (userId, username, avatarUrl, ticketNumber)

Erros:

StatusCondicao
404Raffle nao encontrado

POST /raffle/:id/buy

Autenticacao: Obrigatoria (@UseGuards(AuthGuard))

Params:

ParamTipoDescricao
idstring (convertido para BigInt)ID do raffle

Body:

json
{
  "quantity": 5
}
CampoTipoValidacaoDescricao
quantitynumber@IsInt() @Min(1) @Max(500)Quantidade de tickets a comprar

Response (200):

json
{
  "success": true,
  "tickets": [
    {
      "id": "7777777777777",
      "raffleId": "1234567890123",
      "userId": "1111111111111",
      "ticketNumber": 123
    },
    {
      "id": "7777777777778",
      "raffleId": "1234567890123",
      "userId": "1111111111111",
      "ticketNumber": 124
    }
  ],
  "remainingTickets": 45,
  "totalTicketsSold": 250,
  "maxTickets": 500
}

Erros:

StatusMensagemCondicao
400"Compre entre 1 e 500 tickets por vez"quantity < 1 ou quantity > 500
404"Sorteio nao encontrado"Raffle inexistente
400"Sorteio nao esta ativo"status !== ACTIVE
400"Sorteio encerrado"endsAt < now()
404"Usuario nao encontrado"User inexistente
400"Nao ha tickets suficientes disponiveis"totalTicketsSold + quantity > maxTickets
400"Minimo de X tickets para participar deste sorteio"Primeira compra com quantity < minTicketsPerUser
400"Maximo de X tickets por pessoa neste sorteio"userTicketCount + quantity > maxTicketsPerUser
400"Tickets insuficientes"user.tickets < totalCost

POST /raffle/:id/draw

Autenticacao: Obrigatoria (@UseGuards(AuthGuard)) -- usado pelo admin para trigger manual

Params:

ParamTipoDescricao
idstring (convertido para BigInt)ID do raffle

Body:

json
{
  "clientSeed": "seed-fornecido-pelo-admin"
}
CampoTipoValidacaoDescricao
clientSeedstring@IsString()Client seed para o Provably Fair

Response (200): Mesmo formato de formatRaffle() com todos os prizes atualizados com winners.

Erros:

StatusMensagemCondicao
404"Sorteio nao encontrado"Raffle inexistente
400"Sorteio nao esta ativo"status !== ACTIVE
400"Nenhum ticket vendido, sorteio cancelado"0 tickets vendidos (status muda para CANCELLED)

Eventos WebSocket

Arquivo: src/infrastructure/websocket/websocket.gateway.ts

Todos os eventos de raffle sao broadcasts globais (this.server.emit()), ou seja, todos os clientes conectados recebem, independente de autenticacao.

raffle:ticket_purchased

Emitido apos cada compra de tickets bem sucedida.

Momento de emissao: Apos a transacao atomica de compra e ANTES da verificacao de auto-draw.

typescript
emitRaffleTicketPurchased(
  raffleId: bigint,
  data: {
    userId: string;
    username: string;
    avatarUrl: string;
    quantity: number;
    totalTicketsSold: number;
    maxTickets: number;
  },
)

Payload emitido:

json
{
  "raffleId": "1234567890123",
  "userId": "1111111111111",
  "username": "PlayerOne",
  "avatarUrl": "https://...",
  "quantity": 5,
  "totalTicketsSold": 250,
  "maxTickets": 500
}

raffle:drawing

Emitido quando o sorteio comeca (transicao ACTIVE -> DRAWING).

typescript
emitRaffleDrawing(raffleId: bigint)

Payload emitido:

json
{
  "raffleId": "1234567890123"
}

raffle:results

Emitido apos todos os premios serem sorteados, com o resultado completo de cada premio.

typescript
emitRaffleResults(
  raffleId: bigint,
  prizeResults: {
    prizeId: string;
    displayOrder: number;
    itemId: string;
    itemName: string;
    itemImageUrl: string;
    itemRarity: string;
    valueCentsBrl: string;
    winnerId: string;
    winnerUsername: string;
    winnerAvatarUrl: string;
    winningTicketNumber: number;
    serverSeedHash: string;
    sequenceIndex: number;
    animationDelayMs: number;
  }[],
)

Payload emitido:

json
{
  "raffleId": "1234567890123",
  "prizeResults": [
    {
      "prizeId": "9876543210123",
      "displayOrder": 0,
      "itemId": "5555555555555",
      "itemName": "AWP | Dragon Lore",
      "itemImageUrl": "https://...",
      "itemRarity": "EXTRAORDINARY",
      "valueCentsBrl": "70765",
      "winnerId": "1111111111111",
      "winnerUsername": "PlayerOne",
      "winnerAvatarUrl": "https://...",
      "winningTicketNumber": 42,
      "serverSeedHash": "abc123def456...",
      "sequenceIndex": 0,
      "animationDelayMs": 0
    },
    {
      "prizeId": "9876543210124",
      "displayOrder": 1,
      "itemId": "5555555555556",
      "itemName": "M4A4 | Howl",
      "itemImageUrl": "https://...",
      "itemRarity": "EXTRAORDINARY",
      "valueCentsBrl": "125000",
      "winnerId": "2222222222222",
      "winnerUsername": "PlayerTwo",
      "winnerAvatarUrl": "https://...",
      "winningTicketNumber": 87,
      "serverSeedHash": "def456abc789...",
      "sequenceIndex": 1,
      "animationDelayMs": 8000
    }
  ]
}

raffle:completed

Emitido imediatamente apos raffle:results, sinalizando que o sorteio terminou.

typescript
emitRaffleCompleted(raffleId: bigint)

Payload emitido:

json
{
  "raffleId": "1234567890123"
}

Ordem de emissao no draw

1. raffle:drawing           (status mudou para DRAWING)
2. [premios sorteados]
3. raffle:results           (todos os resultados de uma vez)
4. raffle:completed         (finalizacao)

Hook Frontend: useRaffleSocket

Arquivo: client/app/lib/use-raffle-socket.tsx

Interfaces TypeScript

typescript
export interface RaffleTicketPurchasedData {
  raffleId: string;
  userId: string;
  username: string;
  avatarUrl: string;
  quantity: number;
  totalTicketsSold: number;
  maxTickets: number;
}

export interface RaffleDrawingData {
  raffleId: string;
}

export interface RafflePrizeResult {
  prizeId: string;
  displayOrder: number;
  itemId: string;
  itemName: string;
  itemImageUrl: string;
  itemRarity: string;
  valueCentsBrl: string;
  winnerId: string;
  winnerUsername: string;
  winnerAvatarUrl: string;
  winningTicketNumber: number;
  serverSeedHash: string;
  sequenceIndex: number;
  animationDelayMs: number;
}

export interface RaffleResultsData {
  raffleId: string;
  prizeResults: RafflePrizeResult[];
}

export interface RaffleCompletedData {
  raffleId: string;
}

export interface RaffleSocketEvents {
  onTicketPurchased?: (data: RaffleTicketPurchasedData) => void;
  onDrawing?: (data: RaffleDrawingData) => void;
  onResults?: (data: RaffleResultsData) => void;
  onCompleted?: (data: RaffleCompletedData) => void;
}

Uso do hook

typescript
export function useRaffleSocket(raffleId: string | null, events: RaffleSocketEvents = {})
  • raffleId: Se fornecido, filtra eventos apenas para esse raffle. Se null, recebe eventos de todos os raffles.
  • events: Callbacks opcionais para cada tipo de evento.
  • Retorna { isConnected }.

O hook filtra eventos por raffleId automaticamente:

typescript
const handleTicketPurchased = (data: RaffleTicketPurchasedData) => {
  if (raffleId && data.raffleId !== raffleId) return;
  eventsRef.current.onTicketPurchased?.(data);
};

Eventos com raffleId diferente do informado sao ignorados. Se raffleId e null, todos os eventos passam.


Modulo NestJS

Arquivo: src/presentation/modules/raffle.module.ts

typescript
@Module({
  imports: [DatabaseModule, RedisModule, LockModule, AuthModule, WebSocketModule],
  controllers: [RaffleController],
  providers: [
    BuyRaffleTicketsUseCase,
    GetActiveRafflesUseCase,
    GetRaffleDetailsUseCase,
    GetCompletedRafflesUseCase,
    DrawRaffleUseCase,
    ProvablyFairService,
    NotificationService,
    {
      provide: 'IRaffleRepository',
      useClass: RaffleRepository,
    },
    {
      provide: 'IUserInventoryRepository',
      useClass: UserInventoryRepository,
    },
    TransactionService,
    TransactionRepository,
    UserRepository,
  ],
  exports: [],
})
export class RaffleModule {}

Dependencias:

  • DatabaseModule: PrismaService, SnowflakeService
  • RedisModule: Conexao Redis
  • LockModule: LockService (Redlock)
  • AuthModule: AuthGuard, OptionalAuthGuard
  • WebSocketModule: WebSocketGatewayService
  • Providers locais adicionais: TransactionService, TransactionRepository, UserRepository (usados por logOnly e leitura do usuario)

Locks Distribuidos

Compra de tickets

typescript
this.lockService.withLock(`raffle:buy:${raffleId}`, async () => {
  return this.lockService.withUserBalanceLock(userId, async () => { ... });
}, 10000);

Dois locks aninhados: o externo raffle:buy:{raffleId} (TTL 10s) serializa compras concorrentes no MESMO sorteio (protege a contagem e a numeracao sequencial global); o interno user:balance:{userId} (TTL 10s) serializa compras do mesmo usuario. Juntos previnem race conditions no saldo de tickets e na numeracao dos tickets.

Draw do sorteio

typescript
this.lockService.withLock(`raffle:draw:${raffleId}`, async () => { ... }, 30000);

Chave: raffle:draw:{raffleId}, TTL: 30 segundos.

Previne draws concorrentes do mesmo sorteio (ex: auto-draw disparado por duas compras simultaneas ou auto-draw + draw manual).


Triggers de Draw

Auto-draw (apos compra de tickets)

Quando totalTicketsSold >= maxTickets apos uma compra:

typescript
if (lockResult.totalTicketsSold >= lockResult.maxTickets) {
  const clientSeed = `auto-draw-${Date.now()}`;
  this.drawRaffleUseCase.execute(raffleId, clientSeed, 'AUTO_DRAW').catch((err) => {
    console.error(`[Raffle] Auto-draw failed for raffle ${raffleId}:`, err);
  });
}
  • Executado fire-and-forget (NAO bloqueia a resposta da compra)
  • clientSeed automatico: "auto-draw-{timestamp}"
  • Se falhar, loga erro e pode ser retriggerado manualmente

Draw manual (admin)

Via endpoint POST /raffle/:id/draw com clientSeed fornecido pelo admin.

Draw por expiracao (scheduler)

Via completeExpiredRaffles() que busca raffles ACTIVE com endsAt <= now(). O scheduler externo chama o draw para cada raffle expirado.


Edge Cases

Todos os tickets vendidos durante multiplas compras simultaneas

O lock withUserBalanceLock serializa compras do mesmo usuario. A verificacao totalTicketsSold + quantity > maxTickets dentro do lock previne over-selling. Usuarios diferentes podem comprar concorrentemente (locks diferentes por userId), mas a contagem getTotalTicketsSold e a numeracao sequencial sao protegidas pela transacao atomica do Prisma.

Auto-draw disparado por duas compras simultaneas

O lock raffle:draw:{raffleId} no DrawRaffleUseCase garante que apenas um draw execute. O segundo draw que tentar adquirir o lock ira aguardar. Ao adquirir, encontrara status !== ACTIVE (ja mudou para DRAWING ou COMPLETED) e lancara excecao.

Sorteio com 0 tickets vendidos

O draw muda o status para CANCELLED e lanca BadRequestException('Nenhum ticket vendido, sorteio cancelado').

Mais premios que participantes unicos

Se ha 4 premios e apenas 3 participantes unicos:

  • Premios 1, 2, 3: cada participante ganha um premio (regra one-per-user)
  • Premio 4: eligibleTickets fica vazio apos filtrar excludedUserIds. Reset: eligibleTickets = allTickets. Um dos 3 participantes ganhara um segundo premio.

minTicketsPerUser na primeira compra

Validado apenas quando userTicketCount === 0. Se o usuario ja comprou tickets antes, pode comprar qualquer quantidade (respeitando o max).

ticketPrice diferente de 1

Se ticketPrice = 2 e o usuario compra quantity = 5, o custo total e 5 * 2 = 10 tickets debitados do user.tickets. O usuario recebe 5 RaffleTickets numerados sequencialmente.

Tickets comecam em 0

O primeiro ticket vendido no sorteio tem ticketNumber = 0, nao 1. A numeracao e baseada em totalTicketsSold no momento da compra: se 0 tickets foram vendidos, o primeiro e ticketNumber = 0.


Verificacao Provably Fair

Cada premio pode ser verificado independentemente:

  1. Antes do draw: O serverSeedHash de cada prize e visivel (apos o draw ser executado e salvo no banco)
  2. Apos o draw: O serverSeed e revelado

Como verificar um premio

1. Verificar serverSeedHash:
   HMAC-SHA256(serverSeed) === serverSeedHash

2. Recalcular o roll:
   hmac = HMAC-SHA256(serverSeed)
   hmac.update(clientSeed + ":" + nonce)
   hash = hmac.digest('hex')
   hashPrefix = hash.substring(0, 13)
   decimal = parseInt(hashPrefix, 16)
   roll = (decimal % eligibleTickets.length) + 1

3. Verificar o vencedor:
   winningIndex = roll % eligibleTickets.length
   eligibleTickets[winningIndex].userId === winnerId

Para verificar corretamente, e necessario saber quais tickets eram elegiveis naquele momento (excluindo usuarios que ja ganharam premios anteriores na mesma sequencia).


Debugging

Backend

typescript
// Verificar status do raffle
const raffle = await raffleRepository.findById(id);
console.log('Status:', raffle.status);
console.log('Tickets vendidos:', await raffleRepository.getTotalTicketsSold(id));
console.log('Max tickets:', raffle.maxTickets);
console.log('Premios:', raffle.prizes.length);

// Verificar tickets de um usuario
const userTickets = await raffleRepository.getUserTickets(raffleId, userId);
console.log('User tickets:', userTickets.map(t => t.ticketNumber));

// Verificar saldo de tickets do usuario
const user = await userRepository.findById(userId);
console.log('User tickets balance:', user.tickets);

Logs do WebSocket

[Raffle] Ticket purchased: 5 tickets for raffle 123 by PlayerOne (250/500)
[Raffle] Drawing started for raffle 123
[Raffle] Results for raffle 123: 3 prizes drawn
[Raffle] Completed raffle 123

Auto-draw nao executou

  1. Verificar se totalTicketsSold >= maxTickets
  2. Verificar logs de erro: [Raffle] Auto-draw failed for raffle {id}
  3. Se falhou, executar draw manual via POST /raffle/:id/draw

Ticket nao creditado apos deposito

  1. Verificar deposit.amountCents: deve ser >= 3000 (R$30) para gerar 1 ticket
  2. Formula: Math.floor(amountCents / 3000) = ticketsToAdd
  3. Verificar se o userRepository.update foi executado com sucesso

Arquivos Relacionados

ArquivoDescricao
prisma/schema.prismaModels Raffle, RafflePrize, RaffleTicket, enum RaffleStatus
src/domain/repositories/raffle.repository.interface.tsInterface do repositorio
src/infrastructure/database/repositories/raffle.repository.tsImplementacao Prisma do repositorio
src/application/use-cases/raffle/buy-raffle-tickets.use-case.tsCompra de tickets
src/application/use-cases/raffle/draw-raffle.use-case.tsExecucao do sorteio
src/application/use-cases/raffle/get-active-raffles.use-case.tsListagem de ativos
src/application/use-cases/raffle/get-completed-raffles.use-case.tsListagem de finalizados
src/application/use-cases/raffle/get-raffle-details.use-case.tsDetalhes com tickets do usuario
src/presentation/controllers/raffle.controller.tsController HTTP com formatacao
src/presentation/modules/raffle.module.tsModulo NestJS
src/infrastructure/websocket/websocket.gateway.tsEmissao de eventos WebSocket
src/infrastructure/notification/notification.service.tsNotificacao ao vencedor
src/infrastructure/locks/lock.service.tsLocks distribuidos (Redlock)
src/application/services/provably-fair.service.tsCalculo de roll
src/application/services/transaction.service.tslogOnly (RAFFLE_TICKET_PURCHASE, RAFFLE_PRIZE_WON)
src/infrastructure/audit/audit.service.tsAuditoria (RAFFLE_TICKETS_PURCHASED, RAFFLE_DRAWN)
src/application/services/money.service.tsFormatacao de valores BRL
src/application/services/deposit.service.tsConversao deposito -> tickets
src/infrastructure/database/repositories/user.repository.tsincrementTickets/decrementTickets
client/app/lib/use-raffle-socket.tsxHook frontend para eventos WebSocket

Documentação Técnica - ProCases