Mural Olímpico — Aluno e Responsável
Última atualização: 2026-04-13
Consolidado de:docs/mural/MURAL_OVERVIEW.md+docs/mural/MURAL_LIBERACOES.md
1. Visão Geral
O Mural Olímpico é a interface pública acessível por alunos e responsáveis via /escola/:slug. Cada escola possui um slug único configurado na tabela escola_mural_config. O coordenador controla a publicação de resultados via flags de liberação, que geram snapshots materializados para performance e controle de acesso granular.
Fluxo Geral
/escola/:slug
├── Lookup escola (slug → escola_mural_config)
│ ├── Verifica status da escola (ativa, suspensa, encerrada)
│ ├── Verifica assinatura (subscription-helper.ts)
│ └── Verifica mural_ativo
├── Seleção: "Sou Aluno" | "Sou Responsável"
├── Fluxo Aluno
│ ├── Seleção de série (dropdown agrupado: Médio → Fund. Finais → Fund. Iniciais, ordem decrescente)
│ ├── Método A: Matrícula + Data de Nascimento (input texto com máscara DD/MM/AAAA, inputMode numeric)
│ └── Método B: CPF + OTP WhatsApp
└── Fluxo Responsável
├── Login: CPF + OTP WhatsApp → Seletor de filhos
└── Cadastro: CPF + telefone + nome → OTP WhatsApp → registro global2. Arquitetura
Frontend Backend Banco
──────── ─────── ─────
MuralEscolaPage ─┐ portal-escola escola_mural_config
MuralLoginAluno ─┤ ├── lookup_escola portal_otps
MuralLoginResp. ─┤── useMuralPortal ├── login_aluno_a portal_login_tentativas
MuralCadastro ─┤ ├── send/verify_otp_* alunos, responsaveis
MuralDashAluno ──┤ ├── cadastro_*_otp_responsavel aluno_responsaveis
MuralDashResp. ──┘ ├── get_aluno_dashboard mural_liberacoes
├── auto_vincular/desvincular mural_dados_publicados
├── update_perfil_responsavel resultados_aluno (read)
└── get/save_config
Coordenador (publicação): mural-escola
MuralLiberacoes ── useMuralEscola ─── toggle_liberacao ───────────────► mural_dados_publicados
(snapshot materializado)
Gestão pela escola: gestao-responsaveis
GestaoResponsaveis ───────────────── list/create/update/block ────────► responsaveis
vincular/desvincular aluno_responsaveis3. Schema (Tabelas)
| Tabela | Propósito | RLS |
|---|---|---|
escola_mural_config | Config do mural (slug, métodos de acesso por série, ativo/inativo) | coordenador (SELECT, UPDATE) |
portal_otps | OTPs do mural (SHA-256, 5 min validade) | service_role only |
portal_login_tentativas | Registro de tentativas para rate limiting e lockout | service_role only |
mural_liberacoes | Flags de publicação por fase/nível (liberar_notas, liberar_resultados) | coordenador (SELECT, INSERT, UPDATE) |
mural_dados_publicados | Snapshots materializados para o mural | service_role (write), portal_readonly (read) |
mural_publicacoes | Notícias do mural. Inclui publico_series TEXT[] NULL (filtro opcional por série; ver §7.1 do MURAL_OVERVIEW) e olimpiada_id (filtro implícito por séries participantes da olimpíada) | coordenador (CRUD na própria escola), portal_readonly (SELECT por escola_id, filtrado por série server-side) |
responsaveis | Identidade global do responsável (CPF único) | escola (SELECT, INSERT, UPDATE); portal (SELECT, UPDATE próprio) |
aluno_responsaveis | Vínculos aluno↔responsável com bloqueio institucional | escola (SELECT, INSERT, UPDATE, DELETE); portal (SELECT, INSERT, DELETE próprio) |
responsaveis — Schema Completo
| Coluna | Tipo | Constraints | Observação |
|---|---|---|---|
id | uuid | PK, gen_random_uuid | — |
escola_id | uuid | FK→escolas, nullable, ON DELETE CASCADE | Sempre null — identidade global por CPF |
nome_completo | text | NOT NULL | Editável pelo responsável e pela escola (global) |
cpf | text | NOT NULL, UNIQUE (responsaveis_cpf_key) | Identificador único global |
email | text | nullable | — |
telefone | text | NOT NULL | Usado para OTP WhatsApp |
telefone_secundario | text | nullable | — |
ativo | boolean | NOT NULL, default true | Soft-disable (não é soft-delete) |
criado_em | timestamptz | default now() | — |
atualizado_em | timestamptz | default now() | — |
Modelo de identidade: O responsável é uma entidade global identificada por CPF. O campo
escola_idexiste por legado mas é semprenull. O vínculo com escolas é derivado exclusivamente viaaluno_responsaveis → alunos.escola_id.
aluno_responsaveis — Schema Completo
| Coluna | Tipo | Constraints | Observação |
|---|---|---|---|
aluno_id | uuid | PK (composta), FK→alunos ON DELETE CASCADE | — |
responsavel_id | uuid | PK (composta), FK→responsaveis ON DELETE CASCADE | — |
parentesco | text | NOT NULL, default 'responsavel' | Valores: pai, mae, responsavel |
principal | boolean | default false | Flag informativo, não afeta permissões |
criado_em | timestamptz | default now() | — |
bloqueado_escola | boolean | default false | Bloqueio institucional (impede login no portal da escola) |
bloqueado_em | timestamptz | nullable | Timestamp do bloqueio |
bloqueado_por | uuid | nullable | ID do usuário que bloqueou |
Sem campo de consentimento: Não existe campo de aceite de termos, consentimento LGPD ou timestamp de aceite em nenhuma das tabelas. Ver Auditoria LGPD.
4. Métodos de Acesso
Por Série (escola_mural_config.metodo_acesso_por_serie)
| Método | Campos | Público-alvo | Justificativa |
|---|---|---|---|
| A | Matrícula + Data de Nascimento | Crianças (6º-7º) | Não possuem CPF/telefone |
| B | CPF + OTP WhatsApp | Adolescentes (8º-9º) | Possuem CPF e telefone |
Responsável: sempre CPF + OTP WhatsApp.
5. Autenticação
| Aspecto | Sistema Principal | Mural — Aluno | Mural — Responsável |
|---|---|---|---|
| Secret | OLP_JWT_SECRET | OLP_JWT_SECRET | OLP_JWT_SECRET |
| Cookie | olp_auth | olp_mural | olp_mural |
| Expiração | 8h | 2h | 2h |
| Escopo | Completo (CRUD) | portal_readonly | portal_readonly |
escola_id no JWT | UUID da escola | UUID da escola | null (identidade global) |
Claims do JWT do Responsável
{
"sub": "uuid-responsavel",
"tipo": "responsavel",
"nome_completo": "Nome",
"escola_id": null,
"serie_id": null,
"turma_id": null,
"matricula": null,
"escopo": "portal_readonly"
}
escola_id: nullé intencional. O responsável é global — não pertence a nenhuma escola específica. O acesso aos dados dos filhos é resolvido viaget_alunos_responsavel()(SECURITY DEFINER).
6. Segurança
Rate Limiting — NAT-Aware (Dual Check)
SSOT: Constantes em supabase/functions/_shared/portal-security.ts
Limites Gerais
| Constante | Limite | Janela |
|---|---|---|
RATE_LIMIT_LOOKUP_IP | 100/min | 1 min |
RATE_LIMIT_LOGIN_A_IP | 400/5min | 5 min |
RATE_LIMIT_OTP_IP | 50/15min | 15 min |
BURST_LIMIT_IP | 150/5s | 5s (in-memory) |
Limites Específicos do Responsável
| Constante | Limite | Janela | Usado por |
|---|---|---|---|
RATE_LIMIT_CADASTRO | 30/30min | 30 min | cadastro_enviar_otp_responsavel |
RATE_LIMIT_VINCULO | 30/60min | 60 min | auto_vincular_matricula |
RATE_LIMIT_UPDATE_PERFIL | 10/60min | 60 min | update_perfil_responsavel |
Lockout Progressivo
Login (LOCKOUT_THRESHOLDS)
| Falhas | Lockout | Alerta |
|---|---|---|
| 3 | 2 min | — |
| 6 | 10 min | ntfy high |
| 10 | 60 min | ntfy urgent (possível brute force) |
Vínculo (LOCKOUT_THRESHOLDS_VINCULO)
| Falhas | Lockout |
|---|---|
| 5 | 10 min |
| 8 | 60 min |
| 12 | 120 min |
Outras Proteções
- Anti-timing attack: delay aleatório 500-1000ms em todas as respostas de login
- Validação de CPF:
isValidCPF()antes de qualquer operação de banco - Verificação de assinatura:
subscription-helper.tsantes de permitir login - Parentescos válidos:
PARENTESCOS_VALIDOS_PORTAL = ['pai', 'mae', 'responsavel']
7. Edge Functions
portal-escola
Actions Públicas (sem auth)
| Action | Descrição |
|---|---|
lookup_escola | Busca escola por slug (valida status, assinatura, mural_ativo) |
get_series_escola | Séries ativas da escola (pré-login) |
login_aluno_a | Login Método A — Matrícula + DN |
send_otp_aluno | Envia OTP (Método B) via WhatsApp |
verify_otp_aluno | Valida OTP (Método B) → JWT |
send_otp_responsavel | Envia OTP para responsável |
verify_otp_responsavel | Valida OTP responsável → JWT |
verificar_cpf_responsavel | Verifica se CPF já existe |
cadastro_enviar_otp_responsavel | OTP para cadastro novo |
cadastro_verificar_otp_responsavel | Valida OTP + cadastra → JWT |
check_slug_availability | Verifica disponibilidade de slug |
Actions Autenticadas (Portal — olp_mural)
| Action | Descrição |
|---|---|
verificar_sessao | Verifica validade do token |
get_aluno_dashboard | Dados do dashboard |
auto_vincular_matricula | Vincula aluno ao responsável |
auto_desvincular | Desvincula aluno |
update_perfil_responsavel | Atualiza perfil do responsável |
logout_portal | Logout + limpeza de cookies |
Actions Autenticadas (Sistema — olp_auth)
| Action | Descrição |
|---|---|
get_config | Config do mural da escola (coordenador) |
save_config | Salva config do mural (coordenador) |
mural-escola
| Action | Tipo | Descrição |
|---|---|---|
toggle_liberacao | Write | Publica/despublica resultados → gera snapshots |
gestao-responsaveis
| Action | Tipo | Papel | Descrição |
|---|---|---|---|
list | Read | escola | Lista responsáveis vinculados a alunos da escola |
get | Read | escola | Detalhe de um responsável |
create | Write | escola | Cadastra responsável manualmente |
update | Write | escola | Atualiza dados do responsável (global) |
bloquear | Write | escola | Seta bloqueado_escola=true em aluno_responsaveis |
desbloquear | Write | escola | Seta bloqueado_escola=false em aluno_responsaveis |
vincular | Write | escola | Vincula responsável a aluno da escola |
desvincular | Write | escola | Remove vínculo responsável↔aluno |
8. Responsável — Modelo de Identidade e Fluxos
Identidade Global
O responsável é uma entidade global identificada pelo CPF. Não pertence a nenhuma escola específica:
responsaveis.escola_idé semprenull(campo legado)- O vínculo com escolas é derivado via
aluno_responsaveis → alunos.escola_id - Um mesmo responsável pode ter filhos em múltiplas escolas
- O JWT do responsável tem
escolaId: null
Fluxo de Auto-Cadastro
Handlers: portal-cadastro.ts → handleCadastroEnviarOtpResponsavel, handleCadastroVerificarOtpResponsavel
CPF + telefone + nome_completo
→ Valida CPF (isValidCPF)
→ Verifica duplicata global (responsaveis.cpf)
→ Se CPF já existe → erro "CPF já cadastrado"
→ Envia OTP via WhatsApp
→ Verifica OTP
→ INSERT em responsaveis (escola_id: null, ativo: true)
→ Emite JWT com escolaId: null
→ NENHUM vínculo aluno_responsaveis criadoSem consentimento: Não há coleta de aceite de termos, consentimento LGPD ou qualquer flag de aceite neste fluxo.
Fluxo de Login
Handlers: portal-login-responsavel.ts → handleSendOtpResponsavel, handleVerifyOtpResponsavel
CPF
→ CPF deve existir em responsaveis + ativo=true
→ Verifica vínculos na escola do mural:
aluno_responsaveis JOIN alunos WHERE alunos.escola_id = escola_do_slug
→ Se TODOS os vínculos naquela escola têm bloqueado_escola=true → BLOQUEADO
→ Se nenhum vínculo com a escola → PERMITIDO (login sem restrição)
→ Envia OTP via WhatsApp
→ Verifica OTP
→ JWT com escolaId: null
→ Retorna lista de TODOS os alunos vinculados (TODAS as escolas)Cross-escola: O login pelo mural da Escola A retorna filhos da Escola A e da Escola B. O JWT não restringe por escola.
Auto-Vínculo
Handler: portal-cadastro.ts → handleAutoVincularMatricula
matrícula + data_nascimento + parentesco
→ Busca aluno por matrícula + data_nascimento (qualquer escola ativa)
→ Verifica escola do aluno está ativa
→ Verifica vínculo não existe
→ Valida parentesco ∈ PARENTESCOS_VALIDOS_PORTAL
→ INSERT em aluno_responsaveis (principal: false, bloqueado_escola: false)
→ Rate limit: RATE_LIMIT_VINCULO (30/60min)
→ Lockout: LOCKOUT_THRESHOLDS_VINCULOSem aprovação institucional: O vínculo é criado sem aprovação da escola. A escola pode bloquear depois via
gestao-responsaveis.bloquear.
Auto-Desvinculação
Handler: portal-cadastro.ts → handleAutoDesvincular
aluno_id (do token do responsável)
→ Verifica vínculo existe
→ DELETE de aluno_responsaveis
→ Rate limit: 5 operações/horaDashboard do Responsável
Handler: portal-dashboard.ts → handleGetAlunoDashboard
aluno_id (parâmetro) + responsavel_id (do JWT)
→ Verifica vínculo explícito: aluno_responsaveis WHERE aluno_id AND responsavel_id
→ NÃO verifica bloqueado_escola
→ Busca dados do aluno (alunos)
→ Busca inscrições (inscricoes_olimpiada)
→ Busca snapshots (mural_dados_publicados)
→ Retorna dados de TODAS as escolas/olimpíadas do alunoSem filtro por escola: O dashboard retorna dados de todas as escolas do aluno, independente de qual mural o responsável acessou.
Atualização de Perfil
Handler: portal-escola/index.ts → handleUpdatePerfilResponsavel
nome_completo, email, telefone, telefone_secundario (opcionais)
→ UPDATE em responsaveis WHERE id = sub do JWT
→ Afeta o registro GLOBAL — impacta todas as escolas vinculadas
→ Rate limit: RATE_LIMIT_UPDATE_PERFIL (10/60min)Impacto cross-escola: Se a Escola A e a Escola B compartilham o mesmo responsável, um update de nome pelo portal da Escola A altera o nome visto pela Escola B.
Gestão pela Escola
Edge Function: gestao-responsaveis/index.ts — papel escola obrigatório
- CRUD: list, get, create, update — scoped a alunos da escola via
aluno_responsaveis → alunos.escola_id - Bloqueio: seta
bloqueado_escola=trueno vínculo específico (não global) - Vínculo/Desvínculo: escola pode vincular/desvincular responsáveis de seus alunos
- Update: altera registro GLOBAL em
responsaveis(mesmo impacto cross-escola)
9. RLS e Funções SECURITY DEFINER
Funções SECURITY DEFINER
get_alunos_responsavel()
-- Retorna todos os aluno_ids vinculados ao responsável autenticado
-- NÃO filtra por escola_id, NÃO filtra por bloqueado_escola
SELECT aluno_id FROM aluno_responsaveis
WHERE responsavel_id = (auth.jwt()->>'sub')::uuidImplicação: Responsável bloqueado na Escola A ainda vê dados do aluno via portal da Escola B (se tiver vínculo não-bloqueado lá).
responsavel_vinculado_aluno(p_aluno_id uuid)
-- Verifica se o responsável autenticado tem vínculo com o aluno
EXISTS (
SELECT 1 FROM aluno_responsaveis
WHERE aluno_id = p_aluno_id
AND responsavel_id = (auth.jwt()->>'sub')::uuid
)responsavel_tem_aluno_na_escola(p_responsavel_id uuid)
-- Verifica se o responsável tem pelo menos um aluno na escola do JWT
EXISTS (
SELECT 1 FROM aluno_responsaveis ar
JOIN alunos a ON a.id = ar.aluno_id
WHERE ar.responsavel_id = p_responsavel_id
AND a.escola_id = (auth.jwt()->>'escola_id')::uuid
)Visibilidade por Papel
| Papel | responsaveis SELECT | aluno_responsaveis SELECT | Filtro |
|---|---|---|---|
| escola | ✅ | ✅ | responsavel_tem_aluno_na_escola() — apenas responsáveis com filhos na escola |
| coordenador | ✅ | ✅ | idem escola |
| diretor | ✅ | ✅ | idem escola |
| administrador | ❌ | ❌ | Sem acesso (por design — proteção de privacidade) |
| portal (responsável) | ✅ próprio | ✅ próprios vínculos | sub do JWT = responsavel_id |
10. Comportamento Cross-Escola
O modelo de identidade global cria comportamentos cross-escola que devem ser considerados:
| Cenário | Comportamento | Referência |
|---|---|---|
| Login pelo mural da Escola A | Retorna filhos de TODAS as escolas | handleVerifyOtpResponsavel |
| Dashboard do filho na Escola B | Dados visíveis via mural da Escola A | handleGetAlunoDashboard |
| Bloqueio pela Escola A | Impede login pelo mural da Escola A; NÃO impede acesso via Escola B | handleSendOtpResponsavel |
| Update de perfil pelo portal | Altera registro GLOBAL | handleUpdatePerfilResponsavel |
| Update pela Escola A | Altera registro GLOBAL (impacta Escola B) | gestao-responsaveis/update |
| Desvinculação pelo responsável | Remove vínculo; se último vínculo, registro persiste em responsaveis | handleAutoDesvincular |
11. Liberação de Resultados e Snapshots
Flags de Liberação (mural_liberacoes)
| Flag | Efeito | Exclusividade |
|---|---|---|
liberar_notas | Materializa pontuação, ranking | Mutuamente exclusivo com liberar_resultados |
liberar_resultados | Materializa situação, premiação, ranking | Mutuamente exclusivo com liberar_notas |
Snapshots Materializados (mural_dados_publicados)
O mural NÃO lê diretamente resultados_aluno. Ao publicar, o backend gera snapshots via computeAndUpsertSnapshot() (helper _shared/snapshot-publicados.ts):
- Busca inscrições em chunks de 80 IDs (limite PostgREST)
- Resolve resultados da fase correspondente
- Calcula empates por nível e por série
- Resolve pontuação máxima via fallback
- Upsert em
mural_dados_publicados(chunks de 100)
Recomputação Automática
Snapshots são recomputados em: inserção/importação de resultados, deleção, atualização de prêmios/nota de corte, botão "Republicar snapshot" (🔄).
Limpeza
Quando ambos liberar_notas=false e liberar_resultados=false, snapshots da fase/nível são deletados.
12. Hook Frontend: useMuralPortal
SSOT: src/hooks/useMuralPortal.ts (~750 linhas)
Exports principais: loginAlunoMetodoA, solicitarOtpAluno, verificarOtpAluno, solicitarOtpResponsavel, verificarOtpResponsavel, cadastroEnviarOtp, cadastroVerificarOtp, verificarSessao, logoutPortal, autoVincularMatricula, autoDesvincular, updatePerfilResponsavel.
13. Componentes
src/pages/mural/
├── MuralEscolaPage.tsx Lookup + seleção aluno/responsável
├── MuralLoginAluno.tsx Login aluno (Método A ou B)
├── MuralLoginResponsavel.tsx Login responsável
├── MuralCadastroResponsavel.tsx Cadastro novo responsável
├── MuralDashboardAluno.tsx Dashboard pós-login aluno
└── MuralDashboardResponsavel.tsx Dashboard responsável (seletor de filhos)
src/components/mural-olimpico/
├── mural-liberacoes.tsx Cards de liberação (coordenador)
└── fase-content.tsx Conteúdo por fase (aluno/responsável)Diferenças Visuais
| Aspecto | Aluno | Responsável |
|---|---|---|
| Abas | Notícias + Competidor | SOMENTE Competidor |
| Seletor de filho | ❌ | ✅ |
14. Cache e Invalidação (Coordenador)
| Key | Conteúdo |
|---|---|
['mural-liberacoes'] | Lista de flags de liberação |
['mural-liberacao-stats', 'batch', ...] | Stats por fase/nível |
['gestao-resultados', ...] | Resultados do coordenador |
Toda operação que altera resultados ou liberações DEVE invalidar stats + liberacoes com refetchType: 'all'.
15. Feature Flags
| Flag | Chave | Default | Efeito |
|---|---|---|---|
| Portal inteiro | portal | true (fail-open) | Bloqueia actions não-neutras |
| Acesso aluno | portal.acesso_aluno | true (fail-open) | Bloqueia login aluno |
| Acesso responsável | portal.acesso_responsavel | false (fail-close) | Bloqueia actions responsável |
16. Referências
docs/security/AUTHENTICATION.md— JWT customizado e cookiesdocs/security/RATE_LIMITS.md— Rate limiting e lockoutdocs/security/SECURITY_AUDIT_2026-02-28.md— Relatório de segurançadocs/operations/TROUBLESHOOTING.md— Debug de problemas do muraldocs/audits/AUDIT_RESPONSAVEL_LGPD_2026-04-13.md— Auditoria LGPD do módulo responsável (lacunas de consentimento, cross-escola, portabilidade)
17. Padrões Aplicados
| Padrão | Referência |
|---|---|
| Autenticação JWT separada | → docs/security/AUTHENTICATION.md |
| Rate Limiting NAT-Aware | → docs/security/RATE_LIMITS.md |
| Snapshots Materializados | → _shared/snapshot-publicados.ts |
| Anti-Timing Attack | → docs/security/RATE_LIMITS.md |
| Identidade Global Responsável | → §8 deste documento |
| Bloqueio Institucional | → §8 + §10 deste documento |