Skip to content

Raffles - Sorteios

O sistema de Raffles permite criar sorteios onde usuários compram tickets para concorrer a prêmios, com resultados verificáveis via Provably Fair.

Como Funciona

Modelo de Dados

prisma
model Raffle {
  id              BigInt        @id @default(autoincrement())
  
  name            String
  description     String?
  imageUrl        String?
  
  // Prêmio
  prizeType       RafflePrizeType
  prizeItemId     BigInt?       // Se for item
  prizeValueCents BigInt?       // Se for saldo
  
  // Configuração
  ticketPriceCents BigInt
  maxTickets       Int
  maxTicketsPerUser Int         @default(10)
  
  // Status
  status          RaffleStatus
  soldTickets     Int           @default(0)
  
  // Provably Fair
  serverSeed      String
  serverSeedHash  String
  clientSeed      String?       // Gerado no sorteio
  winningTicket   Int?
  
  // Resultado
  winnerId        BigInt?
  
  // Datas
  startsAt        DateTime
  endsAt          DateTime
  drawnAt         DateTime?
  
  tickets         RaffleTicket[]
  winner          User?         @relation(...)
  prizeItem       Item?         @relation(...)
  
  createdAt       DateTime      @default(now())
  
  @@index([status])
  @@index([endsAt])
}

enum RaffleStatus {
  SCHEDULED       // Agendado
  ACTIVE          // Em andamento
  DRAWING         // Sorteando
  COMPLETED       // Concluído
  CANCELLED       // Cancelado
}

enum RafflePrizeType {
  ITEM            // Item específico
  BALANCE         // Saldo em R$
}

model RaffleTicket {
  id              BigInt    @id @default(autoincrement())
  raffleId        BigInt
  userId          BigInt
  ticketNumber    Int
  
  raffle          Raffle    @relation(...)
  user            User      @relation(...)
  
  purchasedAt     DateTime  @default(now())
  
  @@unique([raffleId, ticketNumber])
  @@index([raffleId])
  @@index([userId])
}

Implementação

Criar Raffle (Admin)

typescript
// create-raffle.use-case.ts
@Injectable()
export class CreateRaffleUseCase {
  async execute(adminId: bigint, dto: CreateRaffleDto): Promise<Raffle> {
    // 1. Validar prêmio
    if (dto.prizeType === 'ITEM') {
      const item = await this.itemRepository.findById(dto.prizeItemId);
      if (!item) {
        throw new ItemNotFoundException();
      }
    }
    
    // 2. Gerar server seed
    const serverSeed = this.provablyFairService.generateServerSeed();
    const serverSeedHash = this.provablyFairService.hashSeed(serverSeed);
    
    // 3. Criar raffle
    const raffle = await this.raffleRepository.create({
      name: dto.name,
      description: dto.description,
      imageUrl: dto.imageUrl,
      prizeType: dto.prizeType,
      prizeItemId: dto.prizeItemId,
      prizeValueCents: dto.prizeValueCents,
      ticketPriceCents: dto.ticketPriceCents,
      maxTickets: dto.maxTickets,
      maxTicketsPerUser: dto.maxTicketsPerUser || 10,
      status: dto.startsAt > new Date() ? 'SCHEDULED' : 'ACTIVE',
      serverSeed,
      serverSeedHash,
      startsAt: dto.startsAt,
      endsAt: dto.endsAt,
    });
    
    // 4. Log de auditoria
    await this.auditService.log({
      action: 'RAFFLE_CREATED',
      adminId,
      resourceId: raffle.id,
      resourceType: 'RAFFLE',
    });
    
    return raffle;
  }
}

Comprar Tickets

typescript
// buy-raffle-tickets.use-case.ts
@Injectable()
export class BuyRaffleTicketsUseCase {
  async execute(
    userId: bigint,
    raffleId: bigint,
    quantity: number,
  ): Promise<RaffleTicket[]> {
    const lockKey = `raffle:${raffleId}:buy`;
    
    return await this.redlock.using([lockKey], 30000, async () => {
      // 1. Buscar raffle
      const raffle = await this.raffleRepository.findById(raffleId);
      
      // 2. Validar status
      if (raffle.status !== 'ACTIVE') {
        throw new RaffleNotActiveException();
      }
      
      // 3. Validar disponibilidade
      const availableTickets = raffle.maxTickets - raffle.soldTickets;
      if (quantity > availableTickets) {
        throw new NotEnoughTicketsException();
      }
      
      // 4. Validar limite por usuário
      const userTickets = await this.raffleTicketRepository.countByUser(
        raffleId,
        userId,
      );
      if (userTickets + quantity > raffle.maxTicketsPerUser) {
        throw new MaxTicketsPerUserException();
      }
      
      // 5. Calcular custo
      const totalCost = raffle.ticketPriceCents * BigInt(quantity);
      
      // 6. Verificar saldo
      const balance = await this.transactionService.getUserBalance(userId);
      if (balance < totalCost) {
        throw new InsufficientBalanceException();
      }
      
      return await this.prisma.$transaction(async (tx) => {
        // 7. Debitar saldo
        await this.transactionService.createTransaction(tx, {
          debitUserId: userId,
          creditUserId: HOUSE_USER_ID,
          amountCents: totalCost,
          type: TransactionType.RAFFLE_TICKET,
          referenceType: 'RAFFLE',
          referenceId: raffleId,
        });
        
        // 8. Gerar números de tickets
        const startNumber = raffle.soldTickets + 1;
        const tickets: RaffleTicket[] = [];
        
        for (let i = 0; i < quantity; i++) {
          const ticket = await tx.raffleTicket.create({
            data: {
              raffleId,
              userId,
              ticketNumber: startNumber + i,
            },
          });
          tickets.push(ticket);
        }
        
        // 9. Atualizar contador
        await tx.raffle.update({
          where: { id: raffleId },
          data: {
            soldTickets: raffle.soldTickets + quantity,
          },
        });
        
        // 10. Verificar se esgotou
        if (raffle.soldTickets + quantity >= raffle.maxTickets) {
          // Agendar sorteio imediato
          await this.scheduleDrawing(raffleId);
        }
        
        return tickets;
      });
    });
  }
}

Executar Sorteio

typescript
// draw-raffle.use-case.ts
@Injectable()
export class DrawRaffleUseCase {
  async execute(raffleId: bigint): Promise<RaffleResult> {
    const lockKey = `raffle:${raffleId}:draw`;
    
    return await this.redlock.using([lockKey], 60000, async () => {
      const raffle = await this.raffleRepository.findById(raffleId);
      
      // 1. Validar status
      if (raffle.status !== 'ACTIVE') {
        throw new RaffleNotActiveException();
      }
      
      // 2. Validar tickets vendidos
      if (raffle.soldTickets === 0) {
        await this.cancelRaffle(raffleId, 'Nenhum ticket vendido');
        throw new NoTicketsSoldException();
      }
      
      // 3. Atualizar status
      await this.raffleRepository.update(raffleId, {
        status: 'DRAWING',
      });
      
      // 4. Gerar client seed (baseado em dados públicos)
      const clientSeed = await this.generatePublicClientSeed();
      
      // 5. Calcular ticket vencedor
      const roll = this.provablyFairService.calculateRoll(
        raffle.serverSeed,
        clientSeed,
        0, // nonce fixo
      );
      
      // Roll é 1-10000000, mapear para 1-soldTickets
      const winningTicket = (roll % raffle.soldTickets) + 1;
      
      // 6. Encontrar dono do ticket
      const winnerTicket = await this.raffleTicketRepository.findByNumber(
        raffleId,
        winningTicket,
      );
      
      return await this.prisma.$transaction(async (tx) => {
        // 7. Entregar prêmio
        if (raffle.prizeType === 'ITEM') {
          await this.inventoryService.addItem(
            tx,
            winnerTicket.userId,
            raffle.prizeItemId,
          );
        } else {
          await this.transactionService.createTransaction(tx, {
            debitUserId: HOUSE_USER_ID,
            creditUserId: winnerTicket.userId,
            amountCents: raffle.prizeValueCents,
            type: TransactionType.RAFFLE_WIN,
            referenceType: 'RAFFLE',
            referenceId: raffleId,
          });
        }
        
        // 8. Finalizar raffle
        await tx.raffle.update({
          where: { id: raffleId },
          data: {
            status: 'COMPLETED',
            clientSeed,
            winningTicket,
            winnerId: winnerTicket.userId,
            drawnAt: new Date(),
          },
        });
        
        // 9. Notificar vencedor
        await this.notificationService.send(winnerTicket.userId, {
          type: 'RAFFLE_WON',
          title: 'Você ganhou o sorteio!',
          message: `Parabéns! Você ganhou o sorteio "${raffle.name}"!`,
        });
        
        // 10. Broadcast resultado
        this.webSocketGateway.broadcastRaffleResult({
          raffleId,
          winningTicket,
          winner: winnerTicket.user,
          prize: raffle.prizeType === 'ITEM' 
            ? raffle.prizeItem 
            : { valueCents: raffle.prizeValueCents },
        });
        
        return {
          raffleId,
          winningTicket,
          winnerId: winnerTicket.userId,
          serverSeed: raffle.serverSeed, // Revelado após sorteio
          clientSeed,
          roll,
        };
      });
    });
  }
  
  private async generatePublicClientSeed(): Promise<string> {
    // Usar dados públicos verificáveis
    // Ex: Hash do último bloco Bitcoin, resultado loteria, etc.
    const bitcoinBlock = await this.fetchLatestBitcoinBlock();
    return bitcoinBlock.hash;
  }
}

Scheduler de Sorteios

typescript
// raffle.scheduler.ts
@Injectable()
export class RaffleScheduler {
  @Cron('* * * * *') // A cada minuto
  async processRaffles() {
    const now = new Date();
    
    // 1. Ativar raffles agendados
    const scheduledRaffles = await this.raffleRepository.findByStatus('SCHEDULED');
    for (const raffle of scheduledRaffles) {
      if (raffle.startsAt <= now) {
        await this.raffleRepository.update(raffle.id, { status: 'ACTIVE' });
        this.webSocketGateway.broadcastRaffleStarted(raffle);
      }
    }
    
    // 2. Sortear raffles que expiraram
    const expiredRaffles = await this.raffleRepository.findExpired();
    for (const raffle of expiredRaffles) {
      try {
        await this.drawRaffleUseCase.execute(raffle.id);
      } catch (error) {
        console.error(`Failed to draw raffle ${raffle.id}:`, error);
      }
    }
  }
}

Frontend

Lista de Raffles

tsx
const RafflesPage = () => {
  const { data: raffles } = useQuery(['raffles'], fetchRaffles);
  
  return (
    <div className="raffles-grid">
      {raffles.map((raffle) => (
        <RaffleCard key={raffle.id} raffle={raffle} />
      ))}
    </div>
  );
};

const RaffleCard = ({ raffle }: { raffle: Raffle }) => {
  const progress = (raffle.soldTickets / raffle.maxTickets) * 100;
  const timeLeft = formatDistanceToNow(raffle.endsAt);
  
  return (
    <div className="raffle-card">
      <img src={raffle.imageUrl} alt={raffle.name} />
      
      <h3>{raffle.name}</h3>
      
      {/* Prêmio */}
      <div className="prize">
        {raffle.prizeType === 'ITEM' ? (
          <ItemPreview item={raffle.prizeItem} />
        ) : (
          <span className="balance-prize">
            R$ {(raffle.prizeValueCents / 100).toFixed(2)}
          </span>
        )}
      </div>
      
      {/* Progresso */}
      <div className="progress">
        <div className="progress-bar" style={{ width: `${progress}%` }} />
        <span>{raffle.soldTickets} / {raffle.maxTickets} tickets</span>
      </div>
      
      {/* Timer */}
      <div className="timer">
        <ClockIcon />
        <span>{timeLeft}</span>
      </div>
      
      {/* Preço */}
      <div className="price">
        R$ {(raffle.ticketPriceCents / 100).toFixed(2)} / ticket
      </div>
      
      <Button asChild>
        <Link href={`/raffles/${raffle.id}`}>Ver Sorteio</Link>
      </Button>
    </div>
  );
};

Página do Raffle

tsx
const RafflePage = ({ raffle }: { raffle: Raffle }) => {
  const [quantity, setQuantity] = useState(1);
  const { myTickets } = useRaffleTickets(raffle.id);
  
  const canBuyMore = myTickets.length < raffle.maxTicketsPerUser;
  const totalCost = raffle.ticketPriceCents * BigInt(quantity);
  
  return (
    <div className="raffle-page">
      {/* Informações do sorteio */}
      <div className="raffle-info">
        <h1>{raffle.name}</h1>
        <p>{raffle.description}</p>
        
        {/* Prêmio em destaque */}
        <div className="prize-showcase">
          {raffle.prizeType === 'ITEM' ? (
            <ItemCard item={raffle.prizeItem} />
          ) : (
            <div className="balance-prize">
              <span>Prêmio:</span>
              <span className="amount">
                R$ {(raffle.prizeValueCents / 100).toFixed(2)}
              </span>
            </div>
          )}
        </div>
      </div>
      
      {/* Comprar tickets */}
      {raffle.status === 'ACTIVE' && (
        <div className="buy-section">
          <h3>Comprar Tickets</h3>
          
          <div className="quantity-selector">
            <Button onClick={() => setQuantity(q => Math.max(1, q - 1))}>
              -
            </Button>
            <span>{quantity}</span>
            <Button onClick={() => setQuantity(q => Math.min(
              raffle.maxTicketsPerUser - myTickets.length,
              q + 1
            ))}>
              +
            </Button>
          </div>
          
          <p>Total: R$ {(Number(totalCost) / 100).toFixed(2)}</p>
          
          <Button
            onClick={() => buyTickets(raffle.id, quantity)}
            disabled={!canBuyMore}
          >
            Comprar {quantity} Ticket{quantity > 1 ? 's' : ''}
          </Button>
          
          {!canBuyMore && (
            <p className="text-sm text-muted">
              Limite de {raffle.maxTicketsPerUser} tickets por usuário
            </p>
          )}
        </div>
      )}
      
      {/* Meus tickets */}
      {myTickets.length > 0 && (
        <div className="my-tickets">
          <h3>Seus Tickets ({myTickets.length})</h3>
          <div className="tickets-grid">
            {myTickets.map((ticket) => (
              <div key={ticket.id} className="ticket">
                #{ticket.ticketNumber}
              </div>
            ))}
          </div>
        </div>
      )}
      
      {/* Provably Fair */}
      <div className="provably-fair">
        <h3>Verificação</h3>
        <p>Server Seed Hash: {raffle.serverSeedHash}</p>
        {raffle.status === 'COMPLETED' && (
          <>
            <p>Server Seed: {raffle.serverSeed}</p>
            <p>Client Seed: {raffle.clientSeed}</p>
            <p>Ticket Vencedor: #{raffle.winningTicket}</p>
          </>
        )}
      </div>
      
      {/* Resultado */}
      {raffle.status === 'COMPLETED' && (
        <div className="result">
          <h2>Resultado</h2>
          <p>Ticket Vencedor: #{raffle.winningTicket}</p>
          <div className="winner">
            <UserAvatar user={raffle.winner} />
            <span>{raffle.winner.username}</span>
          </div>
        </div>
      )}
    </div>
  );
};

Endpoints da API

Listar Raffles

http
GET /api/raffles?status=ACTIVE

Detalhes do Raffle

http
GET /api/raffles/:id

Comprar Tickets

http
POST /api/raffles/:id/tickets
Content-Type: application/json

{
  "quantity": 5
}

Response:

json
{
  "tickets": [
    { "id": "1", "ticketNumber": 45 },
    { "id": "2", "ticketNumber": 46 },
    { "id": "3", "ticketNumber": 47 },
    { "id": "4", "ticketNumber": 48 },
    { "id": "5", "ticketNumber": 49 }
  ],
  "totalCost": 2500,
  "newBalance": 47500
}

Verificar Resultado

http
GET /api/raffles/:id/verify

Response:

json
{
  "raffleId": "123",
  "serverSeed": "abc...",
  "serverSeedHash": "def...",
  "clientSeed": "bitcoin-block-hash...",
  "roll": 45678,
  "totalTickets": 100,
  "winningTicket": 46,
  "calculation": "(45678 % 100) + 1 = 79 -> Ticket #79"
}

Admin - Criar Raffle

http
POST /api/admin/raffles
Authorization: Bearer {adminSessionId}
Content-Type: application/json

{
  "name": "Sorteio de Ano Novo",
  "description": "Concorra a uma AWP Dragon Lore!",
  "prizeType": "ITEM",
  "prizeItemId": "999",
  "ticketPriceCents": 500,
  "maxTickets": 100,
  "maxTicketsPerUser": 10,
  "startsAt": "2024-12-31T20:00:00Z",
  "endsAt": "2025-01-01T00:00:00Z"
}

Troubleshooting

Sorteio Não Executou

typescript
// Verificar status e data
const raffle = await raffleRepository.findById(id);
console.log('Status:', raffle.status);
console.log('Ends at:', raffle.endsAt);
console.log('Sold tickets:', raffle.soldTickets);

// Forçar sorteio manual (admin)
if (raffle.status === 'ACTIVE' && raffle.soldTickets > 0) {
  await drawRaffleUseCase.execute(raffle.id);
}

Ticket Não Creditado

typescript
// Verificar transação
const transaction = await transactionRepository.findByReference(
  raffleId,
  'RAFFLE',
  TransactionType.RAFFLE_TICKET,
);

// Verificar tickets
const tickets = await raffleTicketRepository.findByUser(raffleId, userId);
console.log('Tickets:', tickets.length);

Documentação Técnica CSGOFlip