Implementação do desafio Elite Dev 2026, com Next.js + React no frontend, FastAPI + Python no backend e PostgreSQL como banco. O fluxo foi desenhado para passar pelo cenário completo: catálogo externo → publicação → busca → escolha de assento ou quantidade → Checkout Stripe em sandbox → ingresso com QR → compartilhamento → validação de portaria.
O desafio pede um fluxo ponta a ponta simples e completo, documentação clara e dados semeados. Os opcionais também foram implementados: busca, painel do organizador, cancelamento com devolução ao estoque, mapa de assentos em atualização em tempo real, Docker Compose, testes e preparação para deploy.
Demonstração publicada: https://frontend-iota-ashy-55.vercel.app/
Documentação da API: Swagger — Backend
- Frontend: Next.js 16, React 19, TypeScript, CSS próprio.
- Backend: FastAPI, SQLAlchemy, PyJWT.
- Banco: PostgreSQL 17.
- Integrações externas: TMDb para filmes, Ticketmaster Discovery API para shows e Stripe Checkout para pagamentos em ambiente de teste.
- QR:
qrcode.reactno cliente e token JWT assinado no backend. - Leitura:
html5-qrcode, com digitação manual como fallback. - Infra: Docker Compose.
- Testes: pytest (ingressos, validação de eventos e regras de exclusão).
- Copie
.env.examplepara.env. - Opcionalmente preencha
TMDB_API_KEYpara filmes eTICKETMASTER_API_KEYpara shows. Para testar compras, preenchaSTRIPE_SECRET_KEYcom uma chave de sandbox/teste eSTRIPE_WEBHOOK_SECRETcom o segredo do webhook. - Rode:
docker compose up --buildou para o modo dev
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --buildAcesse:
- Frontend: http://localhost:3000
- API / Swagger: http://localhost:8000/docs
- Health: http://localhost:8000/health
O container do backend cria as tabelas e executa o seed antes de subir a API.
Todos usam senha 123456:
| Papel | |
|---|---|
| Organizador | organizador@elite.dev |
| Cliente 1 | cliente1@elite.dev |
| Cliente 2 | cliente2@elite.dev |
| Portaria | portaria@elite.dev |
Em um banco novo, o seed cria o evento publicado Noite de Cinema Elite com 200 lugares, distribuídos em fileiras de 20.
- Entre com
cliente1@elite.dev. - Acesse Eventos.
- Em um filme, escolha um ou mais assentos; em um show, escolha a quantidade no estoque geral.
- Clique em Continuar para pagamento, revise a compra e abra o Checkout da Stripe.
- No sandbox, use
4242 4242 4242 4242para aprovação,4000 0000 0000 0002para recusa genérica ou4000 0000 0000 9995para saldo insuficiente. Use qualquer data futura e CVC de três dígitos. - Após a confirmação, abra Meus ingressos para ver o QR, copiar o código usado na portaria ou abrir o link compartilhável.
- Abra o link em outra aba ou dispositivo para testar a visualização pública do ingresso.
- Em Meus ingressos, cancele um ingresso válido para demonstrar o reembolso no sandbox e a devolução do estoque.
O frontend usa o Checkout hospedado; portanto, a chave pública da Stripe não é necessária. A chave secreta nunca é enviada ao navegador.
Para receber webhooks enquanto o backend roda no Docker:
stripe login
stripe listen --forward-to localhost:8000/payments/stripe/webhookCopie o segredo whsec_... exibido pela Stripe CLI para STRIPE_WEBHOOK_SECRET no .env. Como as variáveis são injetadas pelo Compose na criação do container, recrie o backend depois de alterar o .env:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build --force-recreate backendSe a criação do pagamento falhar, acompanhe o backend em outro terminal:
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f backendO frontend recebe uma mensagem segura e o backend registra o traceback com o identificador do checkout, evento e tipo da exceção da Stripe. As chaves não são incluídas nessas mensagens pela aplicação.
- Entre com
portaria@elite.dev. - Acesse Portaria.
- Use a câmera para ler o QR ou cole o conteúdo do QR no campo manual.
- O retorno pode ser:
valid— entrada autorizada;invalid— código adulterado, inexistente ou cancelado;already_used— tentativa de reutilização;event_wrong— ingresso pertence a outro evento.
O projeto usa o TMDb como catálogo externo de filmes e a Ticketmaster Discovery API v2 como catálogo de shows. O endpoint GET /external/catalog recebe source=tmdb|ticketmaster e q=<texto>. A busca da Ticketmaster filtra a classificação music e usa a Consumer Key no parâmetro apikey, somente no backend.
Exemplo:
curl 'http://localhost:8000/external/catalog?source=tmdb&q=batman'
curl 'http://localhost:8000/external/catalog?source=ticketmaster&q=coldplay'Sem chave configurada, o endpoint retorna configured=false; o restante do sistema continua funcionando com o catálogo local/seed.
Os limites são validados no formulário e novamente pela API. Título, descrição, data/hora, local, capacidade e preço são obrigatórios; imagem e ID externo são opcionais.
| Campo | Limite |
|---|---|
| Título | 1 a 255 caracteres; não aceita somente espaços |
| Descrição | 1 a 5.000 caracteres; não aceita somente espaços |
| Imagem (URL) | Opcional; URL HTTP/HTTPS com até 500 caracteres |
| Data/hora | No mínimo 24 horas de antecedência e no máximo 10 anos no futuro |
| Local | 1 a 255 caracteres; não aceita somente espaços |
| Tipo | movie (filme) ou show |
| Capacidade | Filme: fixa em 200 cadeiras, 20 por fileira. Show: obrigatória, de 1 a 1.000 ingressos gerais |
| Preço | 0 a 10.000.000 de centavos (até R$ 100.000,00) |
| ID externo | Opcional; até 120 caracteres |
As buscas nos catálogos do TMDb e da Ticketmaster aceitam consultas de até 200 caracteres. Quando há ID externo, a API também garante que TMDb seja usado apenas em filmes e Ticketmaster apenas em shows.
A API é a autoridade de segurança e valida o token e a role em todos os endpoints protegidos. O frontend também aplica guards nas páginas privadas e mostra no header apenas a opção pertinente ao usuário autenticado.
| Área ou ação | Acesso |
|---|---|
| Lista, detalhes, assentos e disponibilidade | Público, inclusive sem login |
| Selecionar assentos ou quantidade e comprar ingressos | Somente client |
| Meus ingressos | Somente client |
| Painel e ações do organizador | Somente organizer |
| Portaria e validação de ingresso | Somente gate |
| Link de ingresso compartilhado | Público, mediante token assinado válido |
Visitantes que tentarem abrir uma página privada são enviados ao login. Usuários autenticados com uma role diferente são enviados à página pública de eventos.
O cadastro permite escolher livremente entre cliente, organizador e portaria. Isso foi mantido de propósito para que qualquer avaliador consiga criar e testar todos os tipos de usuário sem depender de uma conta administrativa. Em produção, organizadores e profissionais de portaria normalmente seriam convidados ou aprovados por um administrador.
Na página Meus ingressos, o cliente pode cancelar um ingresso próprio enquanto ele estiver com status valid. O cancelamento usa POST /tickets/{ticket_id}/cancel e executa na mesma transação:
- ingresso
valid → cancelled; - reserva
confirmed → cancelled; - pagamento
paid → refunded; - unidade de estoque
reserved → available(cadeira no filme ou ingresso geral no show).
Ingressos de outro cliente, já utilizados ou já cancelados não podem ser cancelados. O backend valida a propriedade e o estado do ingresso mesmo que o endpoint seja chamado diretamente.
O organizador pode excluir somente eventos criados por ele. A operação segue estas regras:
- evento futuro sem reserva ativa pode ser excluído;
- evento futuro com reserva
pendingouconfirmednão pode ser excluído; - reservas canceladas ou recusadas não bloqueiam a exclusão, pois o assento já voltou ao estoque;
- evento passado pode ser excluído mesmo que tenha reservas;
- ao excluir um evento passado, ingressos, reservas e assentos relacionados também são removidos.
O endpoint protegido é DELETE /organizer/events/{event_id}. Tentativas de excluir eventos de outro organizador retornam 403; eventos futuros com reservas ativas retornam 409.
Filmes usam capacidade fixa de 200 cadeiras em 10 fileiras de 20. O mapa torna a regra de não vender o mesmo lugar duas vezes observável durante a avaliação.
Shows não expõem assentos: a tela mostra um contador de ingressos disponíveis e uma seleção de quantidade. Internamente, cada ingresso geral ainda é uma unidade de estoque bloqueável, permitindo reutilizar as garantias de concorrência, cancelamento, exclusão e WebSocket sem criar uma segunda arquitetura de reservas.
Filmes e shows iniciam a compra exclusivamente por POST /checkout/sessions, usando o mesmo fluxo de reserva temporária e confirmação de pagamento.
Cada cadeira ou ingresso geral é uma linha Seat. Na reserva, o backend executa SELECT ... FOR UPDATE em ordem estável e só muda available → reserved dentro da mesma transação. Shows usam SKIP LOCKED na escolha automática do estoque. Todas as unidades são validadas antes da primeira alteração: se não houver quantidade suficiente, o lote inteiro é recusado.
O QR não carrega apenas um ID previsível. Ele contém um JWT assinado com segredo exclusivo de ingressos, incluindo ticket_id, event_id e um jti aleatório. O banco guarda o hash do token e o jti; a portaria valida assinatura, identidade e estado persistido antes de marcar o ingresso como usado.
O iat do JWT é derivado da data persistida de criação do ingresso. Assim, a tela Meus ingressos sempre reconstrói exatamente o mesmo token e o link compartilhável continua compatível com o hash salvo no banco. Ao listar ingressos antigos, o backend corrige automaticamente hashes criados antes dessa regra.
Ao criar um checkout, o backend bloqueia as unidades de estoque com SELECT ... FOR UPDATE, cria reservas pending e uma linha em payment_checkouts. O Checkout hospedado pela Stripe recebe o preço calculado exclusivamente no backend; o navegador nunca informa o valor cobrado.
A sessão expira em cerca de 30 minutos, que é o mínimo aceito pela Stripe. Enquanto estiver aberta, os assentos ou ingressos gerais ficam indisponíveis para outros clientes. O fluxo de estados é:
pending— estoque temporariamente reservado e Checkout aberto;processing— método assíncrono concluído no Checkout, mas ainda sem confirmação financeira;paid— webhook ou sincronização autenticada confirmou o pagamento e criou um ingresso por reserva;failed,cancelledouexpired— nenhuma entrada é criada e o estoque volta aavailable;partially_refundedourefunded— um ou todos os ingressos da compra foram cancelados.
O endpoint POST /payments/stripe/webhook valida o corpo original com Stripe-Signature e STRIPE_WEBHOOK_SECRET. A confirmação é idempotente: reenvios do mesmo evento não duplicam ingressos. A página de retorno também consulta a sessão diretamente na Stripe para tolerar atraso na entrega do webhook, mas nunca confia apenas nos parâmetros da URL.
Eventos com preço zero são confirmados internamente sem criar uma cobrança Stripe. O cancelamento de um ingresso pago solicita um reembolso parcial do valor daquele ingresso usando uma chave de idempotência; o estoque só é devolvido se a Stripe aceitar o reembolso.
O link público contém o mesmo token assinado do QR. Assim não existe um segundo mecanismo de identidade do ingresso: o mesmo artefato pode ser mostrado na tela, transformado em QR ou compartilhado por URL.
A tela de compra abre um WebSocket por evento. Depois de uma reserva ou cancelamento, o backend publica seat_updated com a disponibilidade atual; mapas de filmes e contadores de shows refletem o novo estoque sem recarregar a página.
Considerei permitir que a portaria validasse o ingresso somente no horário exato do evento, mas optei por uma regra menos rígida para não tornar a aplicação e o fluxo de demonstração pouco práticos. Reservas e validações na portaria são bloqueadas para eventos de dias anteriores; eventos do dia atual continuam liberados mesmo que o horário de início já tenha passado, e eventos futuros também permanecem liberados.
A comparação usa o dia civil no fuso America/Sao_Paulo, configurável por APP_TIMEZONE. O backend aplica a regra como fonte de verdade: uma tentativa de reserva para data anterior retorna 409, e a portaria responde como ingresso inválido sem marcar o ingresso como utilizado. A tela de assentos também desabilita antecipadamente a seleção nesses eventos.
Em uma aplicação de produção, essa regra poderia ser retomada como uma janela configurável pelo organizador, por exemplo permitindo a entrada algumas horas antes do início e encerrando a validação após o término do evento.
- JWT de sessão tem expiração e deve usar segredos fortes em produção.
- O cadastro aberto de organizador e portaria é uma decisão consciente para facilitar a avaliação de todas as roles; em produção esses papéis exigiriam convite ou aprovação administrativa.
- O QR é assinado, mas o compartilhamento de um ingresso continua sendo compartilhamento de posse; a autenticação de cliente não é usada para bloquear o link público.
- Não há recuperação de senha, e-mail, nota fiscal, revenda ou app nativo porque o próprio desafio exclui esses itens do escopo.
- A Stripe está integrada em sandbox para a demonstração. Com
STRIPE_TEST_MODE=true, o backend recusa chaves que não comecem comsk_test_ourk_test_, evitando cobranças acidentais. Passar para produção exige alterar conscientemente essa opção, usar chaves de produção, HTTPS, revisar os meios de pagamento e configurar outro webhook; este projeto não ativa cobranças reais por padrão. - O seed é voltado para avaliação local e deve ser removido/adaptado em produção.
A interface do organizador não possui edição de eventos de propósito. Uma edição segura teria de bloquear mudanças relevantes depois da emissão de ingressos; como o formulário tem poucos campos e a maioria é preenchida automaticamente ao selecionar um filme ou show, preferi não adicionar essa feature ao desafio.
Também considerei um sistema de cancelamento do evento que notificaria os clientes com ingressos emitidos. A ideia surgiu no último dia, e preferi não introduzir um fluxo novo perto da entrega sem saber se alterações adicionais seriam permitidas depois do prazo e antes da avaliação. A exclusão existente continua deliberadamente limitada pelas regras documentadas acima.
Com o ambiente Docker de desenvolvimento:
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm backend python -m pytest -qOu com Python 3.12 e dependências instaladas:
cd backend
pytest -qOs testes cobrem token assinado e adulteração, limites dos eventos, adaptadores TMDb e Ticketmaster, mapa de 200 cadeiras, estoque geral de shows, compras atômicas, concorrência real no PostgreSQL, esgotamento, cancelamento, RBAC, datas, portaria, exclusão de eventos e o Checkout Stripe: bloqueio temporário, aprovação, falha, cancelamento, propriedade e idempotência do webhook.
O workflow .github/workflows/ci.yml roda em pushes e pull requests para main. Ele executa:
npm ci, lint e build de produção do frontend;- testes do backend com Python 3.12 e PostgreSQL 17, incluindo o cenário concorrente de validação na portaria;
- validação da combinação dos arquivos Docker Compose.
O CD não é duplicado no GitHub Actions: Vercel e Render já observam o repositório e publicam automaticamente depois de um push na branch conectada (autoDeploy: true no render.yaml). O fluxo recomendado é pull request → CI aprovado → merge em main → deploy automático nas plataformas. Para impedir que uma falha chegue a produção, a branch main deve ser protegida exigindo o sucesso do CI antes do merge; um push direto em main pode iniciar o deploy externo antes de o workflow terminar.
URL publicada: https://frontend-iota-ashy-55.vercel.app/
A aplicação foi organizada para separação de responsabilidades entre frontend e API, de forma que o frontend possa estar na Vercel e o backend em outro provedor.
Consulte docs/AI_USAGE.md para registrar o que foi acelerado por IA e quais decisões devem ser atribuídas à análise humana. O arquivo AGENTS.md também foi incluído como artefato de contexto usado durante o desenvolvimento assistido.
- migrar
create_allpara Alembic; - adicionar rate limiting no login e na portaria;
- auditar ações de portaria;
- adicionar gerenciamento administrativo e convites para roles privilegiadas;
- adicionar observabilidade e logs estruturados;
- implementar edição e cancelamento de eventos com regras de imutabilidade, reembolso e notificação dos clientes.
Para uma entrega pública simples, o monorepo foi preparado para separar o frontend e a API:
GitHub
├── frontend/ → Vercel (Next.js)
└── backend/ → Render Free (FastAPI + Docker)
│
└── Supabase Free (PostgreSQL)
O docker-compose.yml continua sendo o caminho recomendado para avaliação local. O deploy público usa os mesmos diretórios e não depende de manter um único servidor para todo o monorepo.
O backend gratuito pode dormir quando fica sem tráfego e demorar alguns segundos para acordar na primeira requisição. Isso é aceitável para o cenário de avaliação, mas deve ser mencionado ao avaliador caso ele abra o projeto após um período sem acesso.
O banco gratuito também tem limites de armazenamento/uso. O desafio é pequeno e o seed contém apenas um evento e poucos usuários, portanto a carga esperada é baixa.