ADR-014 — Premiação por Nível: Exclusividade Global vs Por-Nível
| Campo | Valor |
|---|---|
| Status | ✅ Aceito |
| Data | 2026-06-02 |
| Autores | Equipe OLP |
| Implementação | supabase/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:
- Modo
global: uma linha comnivel_id = NULL.handleSetPremiacaoBatchapaga todas as linhas comnivel_id IS NOT NULLda fase antes de upsertar. - 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. - Diff por hash:
hashFaixasFromRowcompara estado atual vs payload — só linhas modificadas vão para upsert. Idempotência total (zero efeito = zero recompute = zero log de mudança). - Precedência no compute:
getCfgForNivel(nivelId)retornacfgByNivel.get(nivelId) ?? cfgByNivel.get(null) ?? null. Garante back-compat: olimpíadas legadas com só global continuam funcionando. - IDOR: handler valida que
faseIdpertence àescola_iddo usuário e que cadanivelIdpertence àolimpiada_idda fase. Cross-school/cross-olimpíada → 403. - UI:
PremiacaoFaixasModalexpõeToggleGroupGlobal ↔ Por Nível + tabs por nível (usandonivel.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_idjá existia. - ❌ Não adicionamos coluna
modoemfases_olimpiada. O modo é derivado do estado deconfiguracoes_fase_nivel(presença denivel_id IS NULLvsIS 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
| Abordagem | Complexidade | Por que não |
|---|---|---|
| A. Exclusividade no handler batch (escolhido) | Baixa | — |
B. Coluna modo_premiacao em fases_olimpiada | Média | Drift entre coluna e dados reais; migration desnecessária |
| C. Permitir global + por-nível convivendo (override por-nível) | Média | Já é 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_nivel | Alta | Duplica schema; quebra o compute existente; migration de dados legados |
Conformidade
Invariantes garantidos pelo handler
-- 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; -- = 0Como verificar
-- 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);# 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.tsO que viola
- INSERT direto em
configuracoes_fase_nivelfora dehandleSetPremiacaoBatch(ex: handler legadoset_premiacaoquando estendido paranivel_id) sem replicar a regra de cleanup. - UI permitir submit em modo
por_nivelsemnivelIdem cada item — bloqueado por validação no handler (400). - Materializar
modoem outra tabela sem manter sync comconfiguracoes_fase_nivel. - Remover o fallback
cfgByNivel.get(null)emgetCfgForNivel— 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.
getCfgForNiveljá 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_premiacaoantigo. Mitigado por testes unitários cobrindo IDOR, diff, exclusividade, idempotência.
Trade-offs
| Critério | Exclusividade + fallback | Coluna modo em fases |
|---|---|---|
| Migration | Zero | Sim |
| Risco de drift | Zero (modo é derivado) | Real (coluna vs dados) |
| Performance leitura | 1 query da fase | 1 query + 1 join |
| Complexidade do handler | Mé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 peloLOG_ACTION_OPTIONS(resultado.set_premiacaovsresultado.set_premiacao_batch).
Referências
mem://features/results/comprehensive-architecture-standardmem://architecture/olympics-phases-by-level-standard— fases por nível (precondição estrutural)mem://architecture/log-actions-catalog-ssot—resultado.set_premiacao_batchmem://ui/no-uuid-exposure-standard— tabs usamnivel.nome- ADR-003 (action-dispatch) —
set_premiacao_batchsegue o padrão - ADR-008 (registries declarativos) —
LOG_ACTION_OPTIONSSSOT docs/features/RESULTADOS.md§ 14 — Premiação por Nível