Gestão de Resultados — Coordenador
Última atualização: 2026-04-12
1. Visão Geral
Sistema de inserção, gestão e premiação de resultados de olimpíadas. Permite inserção manual e importação em lote (background), definição de nota de corte, faixas de premiação automática/manual, e sincronização com o Mural Olímpico via snapshots materializados. Acessível a coordenador, escola, administrador e especialista.
2. Arquitetura
Frontend Backend Banco
──────── ─────── ─────
InserirDialog (wizard) ─┐ gestao-resultados (orquestrador) resultados_aluno
ResultadosTabela ───────┤ ├── _shared/resultados-queries.ts inscricoes_olimpiada
NotaCorteDialog ────────┤── useGestao ├── _shared/resultados-mutations.ts configuracoes_fase_nivel
PremiacaoDialog ────────┤ Resultados ├── _shared/resultados-config.ts mural_liberacoes
ImportacaoResultados ───┘ ├── _shared/resultados-import.ts mural_dados_publicados
└── _shared/snapshot-publicados.ts importacao_resultados_sessoes3. Schema (Tabelas)
| Tabela | Propósito | RLS |
|---|---|---|
resultados_aluno | Pontuações brutas por fase (inscricao_id, pontuacao, situacao) | coordenador (SELECT, INSERT, UPDATE, DELETE) |
inscricoes_olimpiada | Vínculo aluno↔olimpíada, auto-criado na importação se inexistente | coordenador (SELECT, INSERT) |
configuracoes_fase_nivel | Nota de corte e faixas de premiação (ouro/prata/bronze/menção) por fase+nível | coordenador (SELECT, INSERT, UPDATE, DELETE) |
mural_liberacoes | Flags de publicação (liberar_notas, liberar_resultados) | coordenador (SELECT, INSERT, UPDATE) |
mural_dados_publicados | Snapshots materializados para o mural | service_role (write) |
importacao_resultados_sessoes | Sessões de importação background (status, progresso) | service_role (write), coordenador (SELECT) |
4. Edge Function: gestao-resultados
Orquestrador modular — roteia para handlers em _shared/resultados-*.ts.
Auth: requireRole(["coordenador", "escola", "administrador", "especialista"])
Feature Flag: requireFeatureFlag("resultados")
CORS: getCorsHeaders(req) em todas as respostas incluindo catch.
Queries (resultados-queries.ts)
| Action | Descrição |
|---|---|
batch_init | Consolida olimpíadas + stats + stats_agregados em 1 chamada |
list | Lista olimpíadas com estatísticas de resultados |
list_by_olimpiada | Resultados por olimpíada/fase (paginado, com filtros) |
stats | Estatísticas gerais (total, medalhas, classificados) |
stats_agregados | Estatísticas por série, turma, ranking de competidores |
preview_nota_corte | Preview de classificados/não-classificados para uma nota de corte |
Mutations (resultados-mutations.ts)
| Action | Descrição | Logging |
|---|---|---|
upsert_batch | Inserção em lote (matrícula → inscrição, auto-cria se inexistente) | resultados.upsert_batch |
upsert_single | Inserção/edição individual | resultados.upsert_single |
delete | Exclui resultado (mantém inscrição) | resultados.delete |
delete_with_inscription | Exclui resultado + inscrição | resultados.delete_with_inscription |
delete_fase_results | Limpa fase inteira (cascata completa) | resultados.delete_fase_results |
Config (resultados-config.ts)
| Action | Descrição | Logging |
|---|---|---|
set_nota_corte | Define nota de corte → classifica/desclassifica alunos | resultados.set_nota_corte |
set_premiacao | Define faixas de premiação automática global (ouro/prata/bronze/menção) | resultados.set_premiacao |
set_premiacao_batch | Define faixas em lote — modo global OU por_nivel (exclusivo) → ver ADR-014 | resultado.set_premiacao_batch |
get_configs_fase | Lê faixas + modo detectado (global/por_nivel/vazio) + níveis disponíveis | — |
set_premiacoes_manual | Define premiações manualmente por aluno | resultados.set_premiacoes_manual |
recompute | Recomputa snapshots do mural para fase/nível | resultados.recompute |
Importação Background (resultados-import.ts)
| Action | Descrição |
|---|---|
start_import_session | Cria sessão + EdgeRuntime.waitUntil() para processamento background |
get_import_session_status | Polling de status da sessão (progresso, erros) |
cancel_import_session | Cancela sessão pendente/processando |
Processamento: batches de 50 registros, recomputeSnapshotsIfPublished() ao final.
5. Validação Zero-Trust (Backend)
- Pontuação máxima por nível: Validada no backend via
fases_olimpiada.pontuacao_maxima_por_nivel. Excedentes rejeitados. - Matrícula: Lookup por escola_id + matrícula. Inexistente → erro.
- Série/nível: Elegibilidade verificada — alunos de série incompatível rejeitados como "inaptos" (retornados em
matriculasInaptas). - Inscricão auto-criada: Se aluno existe mas não tem inscrição, a inscrição é criada automaticamente durante
upsert_batch.
6. Hook: useGestaoResultados.ts
Query Keys
| Key | Formato |
|---|---|
all | ['gestao-resultados'] |
olimpiadas | ['gestao-resultados', 'olimpiadas'] |
porOlimpiada | ['gestao-resultados', 'por-olimpiada', olimpiadaId, faseId] |
porOlimpiadaPaginado | ['gestao-resultados', 'por-olimpiada-pag', ...filtros] |
stats | ['gestao-resultados', 'stats', anoEdicao] |
statsAgregados | ['gestao-resultados', 'stats-agregados', anoEdicao] |
batchInit | ['gestao-resultados', 'batch-init', anoEdicao] |
Exports
| Export | Tipo | staleTime |
|---|---|---|
useResultadosBatchInit | Query | 5min |
useResultadosOlimpiadas | Query | 5min |
useResultadosPorOlimpiada | Query | 5min |
useResultadosPorOlimpiadaPaginado | Query | 5min |
useResultadosStats | Query | 5min |
useResultadosStatsAgregados | Query | 5min |
useResultadosMutations | Mutations | — |
Invalidação
Todas as mutations chamam invalidateAll():
await Promise.all([
queryClient.invalidateQueries({ queryKey: resultadosKeys.all }),
queryClient.invalidateQueries({ queryKey: ['mural-liberacao-stats'], refetchType: 'all' }),
queryClient.invalidateQueries({ queryKey: ['mural-liberacoes'], refetchType: 'all' }),
]);Cross-feature: invalida mural-liberacao-stats e mural-liberacoes para sincronia com o Mural.refetchType: 'all' garante refetch mesmo de queries inativas.
Mutations Expostas (boolean wrappers)
| Função | Mutation | Backend Action |
|---|---|---|
inserirResultadosLote | inserirLoteMut | upsert_batch |
inserirResultado | inserirSingleMut | upsert_single |
definirNotaCorte | notaCorteMut | set_nota_corte |
definirPremiacao | premiacaoMut | set_premiacao (modo global, legado) |
definirPremiacaoBatch | premiacaoBatchMut | set_premiacao_batch (global ou por_nivel) |
definirPremiacoesManual | premiacaoManualMut | set_premiacoes_manual |
excluirResultado | excluirMut | delete |
excluirResultadoEInscricao | excluirComInscricaoMut | delete_with_inscription |
excluirResultadosFase | excluirFaseResultadosMut | delete_fase_results |
Query adicional: useConfigsFase(faseId) — hidrata o PremiacaoFaixasModal com modo detectado + faixas atuais + níveis disponíveis. Key isolada por escola_id.
7. Hook de Importação: useImportacaoResultados.ts
Gerencia sessões de importação background com polling:
startImportSession()→ cria sessão e inicia pollingpollSessionStatus()→ verifica progresso a cada 2scancelImportSession()→ cancela sessão ativasessionIdpersistido emlocalStorage(sobrevive navegação)
8. Fluxo "Limpar Fase" — Efeitos Cascata
A action delete_fase_results executa:
- Deleta
resultados_alunoda fase (+filtro por nível se especificado) - Deleta
inscricoes_olimpiadacorrespondentes - Deleta
configuracoes_fase_nivel(nota de corte + faixas) - Recomputa snapshots (vazios → deletados)
- Auto-despublica: seta flags
falseemmural_liberacoes - Varredura de fantasmas: liberações
truesem dados reais → despublica
9. Padrões Aplicados
| Padrão | Referência |
|---|---|
| Importação background (>50 registros) | → Ref: docs/development/IMPORT_SYSTEM_STANDARD.md |
| Shared Data Layer (chunks + derivação em memória) | → Ref: _shared/resultados-queries.ts |
| Snapshots materializados | → Ref: _shared/snapshot-publicados.ts |
| Cross-feature cache invalidation | → Ref: docs/development/REACT_QUERY_CACHE.md |
| Zero-trust validation | → Ref: docs/development/CODING_STANDARDS.md |
| Renderização Atômica | → Ref: docs/development/ATOMIC_RENDERING.md |
| Error handling (getUserFriendlyError) | → Ref: docs/development/CODING_STANDARDS.md |
10. Testes
| Arquivo | Tipo | Cobertura |
|---|---|---|
gestao-resultados/index.test.ts | Contract (Deno) | CORS, auth, anti-leak |
_shared/__tests__/resultados-config-batch.test.ts | Unit (Deno) | handleSetPremiacaoBatch: validação, IDOR, diff por hash, exclusividade, idempotência |
_shared/__tests__/resultados-compute-por-nivel.test.ts | Unit (Deno) | computeSituacaoFromConfig + getCfgForNivel (precedência nível>global>null, situação manual preservada na última fase, ranking por nível, empates) |
src/hooks/__tests__/useGestaoResultados.premiacao-batch.test.ts | RTL | definirPremiacaoBatch, useConfigsFase: fetch gating, payload, invalidação dupla |
src/components/coordenador/resultados/__tests__/premiacao-faixas-modal.test.tsx | RTL | Toggle Global↔Por Nível, tabs por nível (sem UUID), validação local, submit |
e2e/resultados-premiacao-por-nivel.spec.ts | E2E (Playwright) | Fluxo coordenador: modal → modos → faixas por nível → situações distintas na tabela |
mural-olimpico/__tests__/fase-content.test.tsx | Component | Renderização condicional de resultados |
11. Navegação
- sections-registry.ts:
id: 'resultados',permissionKey: 'resultados' - Feature flag:
resultados(deve estar ativa emfeature_flags)
12. Scoping por nivel_id (Fases por Nível)
Adicionado em: 2026-04-12
Para olimpíadas com modo_config_fases = 'por_nivel', todas as queries que buscam "fase anterior" ou "última fase" devem escopar por nivel_id. Sem esse scoping, queries com .single() falham com PGRST116 (múltiplas linhas) porque níveis diferentes compartilham a mesma ordem.
Padrão Obrigatório
let q = supabase.from("fases_olimpiada")
.select("*")
.eq("olimpiada_id", olimpiadaId)
.eq("ordem", targetOrdem);
if (faseAtual.nivel_id) {
q = q.eq("nivel_id", faseAtual.nivel_id);
} else {
q = q.is("nivel_id", null);
}
const { data } = await q.maybeSingle(); // NUNCA .single()Módulos Afetados
| Módulo | Função | Scoping |
|---|---|---|
resultados-queries.ts | Busca fase anterior para preview | ✅ Escopado |
resultados-mutations.ts | handleUpsertBatch, handleUpsertSingle — fase anterior | ✅ Escopado |
resultados-compute.ts | ehUltimaFase — detecção de última fase | ✅ Escopado |
resultados-import.ts | Importação background — última fase | ✅ Escopado |
→ Ref: docs/audits/AUDIT_FASES_POR_NIVEL_2026-04-12.md
14. Premiação por Nível (ADR-014)
Adicionado em: 2026-06-02
Olimpíadas multi-nível (OMU/OBI/…) permitem faixas distintas por nível em uma única operação batch.
Modos exclusivos
| Modo | Estado em configuracoes_fase_nivel |
|---|---|
global | 1 linha com nivel_id IS NULL |
por_nivel | N linhas, uma por nível, nivel_id IS NOT NULL |
vazio | sem linhas para a fase |
Não há modo misto. handleSetPremiacaoBatch apaga o lado oposto antes de upsertar.
Decisão de qual modo aplicar (compute)
recompute → getCfgForNivel(nivelId do aluno)
├── existe cfg do nivel? → usa nivel
├── senão, existe cfg global? → usa global (back-compat)
└── senão → situação = "aguardando"Fluxo coordenador
- Coordenador abre Definir Premiações na fase desejada.
- Modal lê via
get_configs_fase(modo detectado + faixas atuais + níveis disponíveis). - Toggle Global ↔ Por Nível. Se houver dados do outro modo, modal de confirmação avisa que serão apagados.
- Em Por Nível, tabs por nível usam
nivel.titulo(nunca UUID). Atalho "aplicar a todos os níveis" copia faixas entre tabs e marca todos como editados (touched) para que o save persista a réplica. - Validação local: negativo,
min > max, faixas sobrepostas — bloqueiam submit. - Submit dispara
set_premiacao_batchcom diff por hash; só linhas alteradas vão para upsert. 1 recompute + 1 snapshot ao final.
Semântica de save incremental (modo por_nivel)
Contrato: o payload de
set_premiacao_batchem modopor_nivelcontém apenas níveis editados nesta sessão (touched) ou já persistidos no backend ao abrir o modal (existentes). Níveis nunca tocados e ainda sem config NÃO vão no payload.
- O modal não seeda defaults sintéticos no state. Valores como
90/80/70/60aparecem apenas comoplaceholdervisual nos<Input />— não viram write. handleSetPremiacaoBatchfaz upsert fiel do payload. Em modopor_nivel, o único cleanup é remover a linha global (nivel_id IS NULL) da fase; níveis ausentes do payload são preservados intactos no DB.- Coordenador pode salvar um nível, fechar o modal, reabrir e editar outro — sem risco do segundo save apagar o primeiro.
- Recompute é reativo: apenas reclassifica conforme as faixas efetivamente persistidas. Nunca inventa faixas.
→ Ver: PROBLEM_SOLVING — Caso 19 para o anti-padrão "synthetic UI default promovido a write real".
Invariantes & segurança
- IDOR:
faseIddeve pertencer àescola_iddo JWT; cadanivelIddeve pertencer àolimpiada_idda fase. Cross-school/cross-olimpíada → 403. - Idempotência: zero mudanças → zero recompute → zero log de update.
- Snapshot do mural só refresh quando há diff real.
→ Ver: ADR-014 — Premiação por Nível
15. Override manual de situação (set_situacao_aluno)
Coordenador pode ajustar classificação ou premiação de um aluno individual em qualquer fase, sobrevivendo a recálculos futuros enquanto a situação continuar coerente com a config.
Casos de uso reais
- Polo de aplicação — vaga extra concedida pela coordenação geral mesmo abaixo da nota de corte.
- Desempate manual — duas pontuações iguais na fronteira da faixa; decisão pedagógica de quem sobe.
- Correção pós-fato — revisão de prova ajusta nota sem alterar a classificação previamente comunicada.
Action set_situacao_aluno
invokeAction('gestao-resultados', 'set_situacao_aluno', {
inscricaoId: uuid,
faseId: uuid,
situacao?: 'medalha_ouro' | 'medalha_prata' | 'medalha_bronze'
| 'mencao_honrosa' | 'sem_premiacao'
| 'classificado' | 'nao_classificado',
pontuacao?: number | null, // null = limpar; undefined = não tocar
origem: 'manual' | 'auto', // 'auto' reseta o override
});Regras:
origem='manual'→situacaoobrigatória e validada contraconfiguracoes_fase_nivel(faixas vs nota_corte). Persistesituacao_origem='manual'.origem='auto'→situacaoignorada; backend recalcula viacomputeSituacaoFromConfig(pontuacao, cfg). Persistesituacao_origem='auto'.- Pontuação acima do máximo da fase →
400. - Inscrição de outra escola →
403(ownership).
Preservação no recompute
recomputeFaseForOlimpiada (chamado por toda mutation em lote) preserva situacao_origem='manual' somente enquanto isSituacaoValidaParaConfig(situacao, cfg) for verdadeiro. Se a config mudou de forma que invalide o override (ex.: faixas removidas, medalha_ouro em fase só-nota-corte), o sistema rebaixa silenciosamente para auto — fail-safe.
Observabilidade
Toda mutation em lote retorna manualPreservados: number (contagem total na fase). O frontend exibe aviso olpToast.info quando > 0 após upsert_batch, orientando o coordenador a revisar.
UI
- Botão "Editar" (ícone
Pencil) por linha emDetalhesTabelaabreEditarResultadoAlunoDialog. - Badge "Manual" + tooltip quando
situacaoOrigem === 'manual'. - Switch "Voltar para cálculo automático" envia
origem='auto'semsituacao(não vaza estado obsoleto).
→ Ver: ADR-015 — Manual Override de Situação
16. Referências
docs/features/MURAL.md— Liberação de resultados e snapshotsdocs/development/IMPORT_SYSTEM_STANDARD.md— Padrão de importaçãodocs/security/AUTHENTICATION.md— Auth e rolesdocs/audits/RESULTADOS_REFACTOR_AUDIT.md— Auditoria de refactordocs/audits/AUDIT_FASES_POR_NIVEL_2026-04-12.md— Auditoria fases por níveldocs/architecture/adrs/ADR-014-premiacao-por-nivel.md— Exclusividade global vs por-níveldocs/architecture/adrs/ADR-015-manual-override-situacao-aluno.md— Override manual (situacao_origem)