O problema de “quem pode submeter o quê”
Parece simples: o participante inscrito pode submeter trabalhos. Mas aí você começa a destrinchar as regras reais e percebe que é bem mais complexo.
No JoBoEco, as regras de elegibilidade para submissão incluem:
- O evento está com submissões abertas? (flag global configurável pelo admin)
- O participante está inscrito no evento? (verificação básica)
- O participante tem sua inscrição paga? (ou é isento de pagamento por alguma razão)
- A categoria do participante permite submissão nesta área?
- Se a categoria exige comprovante de matrícula, ele foi enviado e aprovado?
- O participante ainda tem cota de submissões gratuitas disponíveis na sua categoria?
- Se a cota gratuita esgotou, existe uma taxa de submissão para essa área — e ela foi paga?
- O limite global de submissões do evento ainda não foi atingido?
Oito dimensões de verificação. Cada uma pode ser configurada independentemente pelo admin. E qualquer alteração nas configurações precisa ser refletida imediatamente, sem redeploy.
Na primeira versão do sistema, esse código estava espalhado pelo handler da API de submissão — uma série de ifs aninhados que misturava validação, acesso ao banco e resposta HTTP. Funcionava, mas era impossível de testar e difícil de modificar sem quebrar alguma outra regra.
A refatoração para serviço puro
A decisão foi extrair toda a lógica de elegibilidade para um módulo separado, src/lib/eligibility.ts, que:
- Não chama o banco diretamente — recebe como parâmetro tudo que precisa
- Não faz side effects — não atualiza nada, apenas avalia
- Retorna um tipo estruturado — não uma string de erro, mas um objeto com contexto completo
- É testável isoladamente — você pode escrever testes unitários sem mock de banco de dados
O contrato ficou assim:
export type EligibilityStatus = | 'ELIGIBLE' | 'NOT_ENROLLED' | 'ENROLLMENT_PROOF_PENDING' | 'ENROLLMENT_PROOF_REJECTED' | 'FREE_QUOTA_EXHAUSTED_NO_PAID_OPTION' | 'NEEDS_SUBMISSION_PAYMENT' | 'SUBMISSIONS_CLOSED' | 'GLOBAL_LIMIT_REACHED' | 'CATEGORY_NOT_ALLOWED'
export interface EligibilityContext { status: EligibilityStatus eligible: boolean // contexto adicional para o frontend renderizar a mensagem certa freeSubmissionsUsed?: number freeSubmissionsTotal?: number submissionFee?: number pendingPaymentId?: string}
export function checkSubmissionEligibility( input: EligibilityInput,): EligibilityContext { // toda a lógica aqui, sem side effects}O retorno eligible: boolean é a resposta rápida para o frontend decidir se mostra o botão de submissão. O status enum permite renderizar uma mensagem específica para cada caso. Os campos opcionais fornecem contexto para casos que precisam de UX específica (como mostrar quantas submissões ainda tem disponíveis).
Por que retornar contexto em vez de lançar exceção
A primeira versão lançava uma EligibilityError com uma mensagem. O problema: a mensagem era string, e o frontend precisava parsear o texto para decidir como exibir o erro.
Isso é uma violação do princípio de que o contrato entre camadas deve ser tipado, não baseado em strings mágicas. Com o tipo EligibilityStatus, o frontend pode fazer:
switch (eligibility.status) { case "ENROLLMENT_PROOF_PENDING": return <ProofPendingBanner />; case "NEEDS_SUBMISSION_PAYMENT": return <SubmissionPaymentFlow fee={eligibility.submissionFee} />; case "FREE_QUOTA_EXHAUSTED_NO_PAID_OPTION": return <QuotaExhaustedMessage used={eligibility.freeSubmissionsUsed} />; // ...}Cada estado produz um componente diferente, com props tipadas. Zero parsing de string.
A revisão por pares: três modos, uma tabela
O JoBoEco suporta três modos de revisão:
- OPEN: avaliador vê o nome do autor, autor vê o nome do avaliador
- SINGLE_BLIND: avaliador vê o nome do autor, autor não vê quem está avaliando
- DOUBLE_BLIND: nem avaliador vê o autor, nem autor vê o avaliador
Essa configuração fica em siteSettings.reviewMode e é aplicada no nível da API — não no frontend. Isso é importante: nunca confiar no cliente para censurar dados sensíveis.
const reviewMode = await getSiteConfig('reviewMode')
// Se double blind, remove dados do autor antes de retornar para o avaliadorif (reviewMode === 'DOUBLE_BLIND' && userIsReviewer) { submission.authorName = null submission.authorEmail = null submission.authorInstitution = null}A atribuição de avaliadores para submissões é feita por área de conhecimento — cada avaliador é associado a uma ou mais áreas, e o admin atribui submissões dentro dessas áreas. Isso evita atribuir um especialista em Ecologia para avaliar um trabalho de Botânica Sistemática.
O modelo de dados da atribuição ficou assim:
reviewerAreaAssignments { reviewerId, -- usuário com role AVALIADOR areaId, -- área de conhecimento eventId -- específico por evento}
submissionReviews { submissionId, reviewerId, score, -- numérico recommendation, -- APROVADO | REPROVADO | REVISAO comments, -- feedback para o autor createdAt}Uma decisão que tomei conscientemente: não implementar auto-atribuição de avaliadores. O algoritmo de matching é tentador de automatizar (balancear carga por avaliador, verificar conflito de interesse, garantir pelo menos N avaliações por submissão), mas para o volume do JoBoEco, a complexidade não justifica o benefício. O admin faz a atribuição manual pelo painel e tem controle total sobre o processo. Simples ganha.
O painel administrativo como eixo central
Uma das melhores decisões arquiteturais foi tornar o painel admin o lugar onde toda configuração de comportamento é gerenciada em runtime.
Coisas que o admin pode alterar sem redeploy:
- Abrir/fechar submissões (
submissoesAbertas) - Abrir/fechar publicação dos anais (
anaisAtivo) - Modo de revisão (open / single-blind / double-blind)
- Limite global de submissões por evento
- Cotas de submissão gratuita por categoria
- Taxa de submissão por área
- Signatários dos certificados (nome e cargo)
- Conteúdo dos editais
Isso é implementado como um siteSettings no banco D1, com chave-valor:
// Leituraconst value = await getSiteConfig('reviewMode')
// Atualização pelo adminawait setSiteConfig('reviewMode', 'DOUBLE_BLIND')O benefício prático: durante o evento, a organização pode mudar o modo de revisão, fechar submissões ou atualizar os signatários dos certificados sem precisar me chamar para fazer um deploy. Para um sistema usado em contextos acadêmicos formais, essa autonomia é importante.
O anti-padrão que evitei
Existe um anti-padrão comum em sistemas como esse: colocar lógica de negócio em componentes React ou em API routes de forma distribuída.
O resultado disso é que a mesma verificação — “o usuário pode submeter?” — fica replicada em N lugares: no componente que mostra o botão, na API que processa a submissão, no componente de listagem que mostra o status. Qualquer mudança de regra precisa ser propagada para todos esses lugares manualmente.
A solução é antiga e não tem nada de novo: uma função de domínio pura que sabe tudo sobre elegibilidade, chamada uma vez. O componente exibe o resultado. A API valida novamente (você nunca confia só no cliente). A lógica fica em um lugar só.
Handler → checkSubmissionEligibility(input) → EligibilityContext → render UIAPI → checkSubmissionEligibility(input) → EligibilityContext → aceita ou rejeitaParece óbvio no papel. É difícil de manter quando você está com pressa.
Testando o motor de eligibilidade
Esse foi o módulo com mais testes unitários do projeto. Não por dogma, mas porque a complexidade justifica: quando você tem 8 dimensões de verificação e qualquer combinação pode ser o caso real de um usuário, você precisa de cobertura para manter confiança nas mudanças.
O padrão de teste ficou assim:
describe('checkSubmissionEligibility', () => { it('retorna ELIGIBLE quando todas as condições são atendidas', () => { const result = checkSubmissionEligibility({ submissionsOpen: true, isEnrolled: true, enrollmentPaid: true, categoryAllowsSubmission: true, enrollmentProofRequired: false, freeQuotaUsed: 0, freeQuotaTotal: 2, globalLimitReached: false, })
expect(result.eligible).toBe(true) expect(result.status).toBe('ELIGIBLE') })
it('retorna ENROLLMENT_PROOF_PENDING quando prova pendente bloqueia', () => { const result = checkSubmissionEligibility({ // ... enrollmentProofRequired: true, enrollmentProofStatus: 'PENDING', })
expect(result.eligible).toBe(false) expect(result.status).toBe('ENROLLMENT_PROOF_PENDING') })
// ... um teste por caminho bloqueável})Cada estado de bloqueio tem seu próprio teste. Qualquer refatoração nas regras quebra os testes antes de quebrar a produção.
Na próxima — e última — parte da série, falo dos dois sistemas que mais me orgulho no projeto: os certificados com verificação offline via HMAC, e a publicação de anais com busca textual e exportação BibTeX. Mais o check-in via QR Code e os aprendizados do primeiro evento real.