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=ACTIVEDetalhes do Raffle
http
GET /api/raffles/:idComprar 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/verifyResponse:
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);