Skip to content

ADR-014 — Premiação por Nível: Exclusividade Global vs Por-Nível

CampoValor
Status✅ Aceito
Data2026-06-02
AutoresEquipe OLP
Implementaçãosupabase/functions/_shared/resultados-config.ts (handleSetPremiacaoBatch), supabase/functions/_shared/resultados-queries.ts (handleGetConfigsFase), supabase/functions/_shared/resultados-compute.ts (getCfgForNivel), src/components/coordenador/resultados/premiacao-faixas-modal.tsx, src/hooks/useGestaoResultados.ts (definirPremiacaoBatch, useConfigsFase)

Contexto

A tabela configuracoes_fase_nivel sempre suportou faixas de premiação por (escola_id, fase_id, nivel_id), com nivel_id = NULL significando "configuração global da fase". Porém, a UI só expunha o modo global — todos os níveis (A, B, C…) eram forçados a compartilhar as mesmas faixas (ex: 90–100 ouro).

Em olimpíadas como OMU/OBI, faixas são intrinsecamente diferentes por nível: aluno de nível A pode receber ouro com 40–50 pontos, enquanto nível B exige 90–100. Coordenadores estavam premiando manualmente um a um, ou criando olimpíadas "duplicadas" por nível (caso "Mario" reportado em maio/2026).

recomputeFaseForOlimpiada já implementava getCfgForNivel com fallback nível → global → null, mas sem UI/handler para popular as linhas por-nível, esse fallback nunca era exercitado em produção.

Decisão

Adotamos exclusividade explícita de modo entre configuração global (nivel_id IS NULL) e por-nível (nivel_id específico), exposta por um único handler batch + UI com toggle:

  1. Modo global: uma linha com nivel_id = NULL. handleSetPremiacaoBatch apaga todas as linhas com nivel_id IS NOT NULL da fase antes de upsertar.
  2. Modo por_nivel: N linhas (uma por nível da olimpíada). O handler apaga a linha global (nivel_id IS NULL) antes de upsertar as por-nível. nivelId é obrigatório em cada item.
  3. Diff por hash: hashFaixasFromRow compara estado atual vs payload — só linhas modificadas vão para upsert. Idempotência total (zero efeito = zero recompute = zero log de mudança).
  4. Precedência no compute: getCfgForNivel(nivelId) retorna cfgByNivel.get(nivelId) ?? cfgByNivel.get(null) ?? null. Garante back-compat: olimpíadas legadas com só global continuam funcionando.
  5. IDOR: handler valida que faseId pertence à escola_id do usuário e que cada nivelId pertence à olimpiada_id da fase. Cross-school/cross-olimpíada → 403.
  6. UI: PremiacaoFaixasModal expõe ToggleGroup Global ↔ Por Nível + tabs por nível (usando nivel.nome, nunca UUID). Atalho "aplicar faixas a todos os níveis". Validação local (negativo, min>max, sobreposição) antes do submit.

Decisões correlatas que NÃO foram tomadas:

  • ❌ Não criamos tabela nova nem coluna nova. A coluna nivel_id já existia.
  • ❌ Não adicionamos coluna modo em fases_olimpiada. O modo é derivado do estado de configuracoes_fase_nivel (presença de nivel_id IS NULL vs IS NOT NULL). Materializar seria fonte de drift.
  • ❌ Não permitimos modos misturados (global + por-nível na mesma fase). Ambiguidade desnecessária — o fallback do compute já cobre back-compat.

Alternativas consideradas

AbordagemComplexidadePor que não
A. Exclusividade no handler batch (escolhido)Baixa
B. Coluna modo_premiacao em fases_olimpiadaMédiaDrift entre coluna e dados reais; migration desnecessária
C. Permitir global + por-nível convivendo (override por-nível)MédiaJá é o comportamento de leitura via getCfgForNivel, mas dois modos ativos confunde o coordenador na UI; gera dúvida sobre qual aplica
D. Tabela separada premiacao_faixas_por_nivelAltaDuplica schema; quebra o compute existente; migration de dados legados

Conformidade

Invariantes garantidos pelo handler

sql
-- Após handleSetPremiacaoBatch com modo='global':
SELECT count(*) FROM configuracoes_fase_nivel
WHERE fase_id = $1 AND nivel_id IS NOT NULL;  -- = 0

-- Após handleSetPremiacaoBatch com modo='por_nivel':
SELECT count(*) FROM configuracoes_fase_nivel
WHERE fase_id = $1 AND nivel_id IS NULL;       -- = 0

Como verificar

sql
-- Fases com modo misturado (deve retornar 0 linhas após este ADR):
SELECT fase_id
FROM configuracoes_fase_nivel
GROUP BY fase_id
HAVING bool_or(nivel_id IS NULL) AND bool_or(nivel_id IS NOT NULL);
bash
# Testes que cobrem a invariante:
deno test --no-check supabase/functions/_shared/__tests__/resultados-config-batch.test.ts
deno test --no-check supabase/functions/_shared/__tests__/resultados-compute-por-nivel.test.ts

O que viola

  • INSERT direto em configuracoes_fase_nivel fora de handleSetPremiacaoBatch (ex: handler legado set_premiacao quando estendido para nivel_id) sem replicar a regra de cleanup.
  • UI permitir submit em modo por_nivel sem nivelId em cada item — bloqueado por validação no handler (400).
  • Materializar modo em outra tabela sem manter sync com configuracoes_fase_nivel.
  • Remover o fallback cfgByNivel.get(null) em getCfgForNivel — quebra olimpíadas legadas só com global.

Consequências

Positivas:

  • Coordenador define faixas distintas por nível em uma única operação batch.
  • Zero migration de dados: olimpíadas existentes continuam funcionando (fallback global).
  • Diff por hash minimiza recomputes e logs.
  • getCfgForNivel já existente fica cristalizado em testes (Entrega 4: 14 testes verdes).
  • Precedência nível>global é determinística e explícita.

Negativas:

  • Trocar de modo destrói o outro lado (modal de confirmação obrigatório na UI).
  • Handler batch é mais complexo que set_premiacao antigo. Mitigado por testes unitários cobrindo IDOR, diff, exclusividade, idempotência.

Trade-offs

CritérioExclusividade + fallbackColuna modo em fases
MigrationZeroSim
Risco de driftZero (modo é derivado)Real (coluna vs dados)
Performance leitura1 query da fase1 query + 1 join
Complexidade do handlerMédia (cleanup explícito)Baixa

Débito técnico

  • A action legada set_premiacao (single, modo global) continua existindo. Não há plano de remoção — chamada por integrações antigas e ainda válida quando coordenador quer global puro. Convivência supervisionada pelo LOG_ACTION_OPTIONS (resultado.set_premiacao vs resultado.set_premiacao_batch).

Referências

  • mem://features/results/comprehensive-architecture-standard
  • mem://architecture/olympics-phases-by-level-standard — fases por nível (precondição estrutural)
  • mem://architecture/log-actions-catalog-ssotresultado.set_premiacao_batch
  • mem://ui/no-uuid-exposure-standard — tabs usam nivel.nome
  • ADR-003 (action-dispatch) — set_premiacao_batch segue o padrão
  • ADR-008 (registries declarativos) — LOG_ACTION_OPTIONS SSOT
  • docs/features/RESULTADOS.md § 14 — Premiação por Nível