Multi-Escola — Documentação Completa
Última atualização: 2026-02-24
Status da Implementação
| Etapa | Descrição | Status |
|---|---|---|
1. select-role | Aceita { papel, escola_id }, valida contra usuario_papeis | ✅ Implementado |
2. verify-otp | JWT escola_id derivado de usuario_papeis, bloqueios multi-escola | ✅ Implementado |
3. /me | Bloqueios verificados para todas as escolas, retorna escola_nome por papel | ✅ Implementado |
| 4. Login UI | Tela de seleção agrupa por perfil + escola, cards distintos por escola | ✅ Implementado |
| 5. Role Switcher | Popover lista perfis com nome da escola, troca envia escola_id | ✅ Implementado |
| 6. Admin CRUD | admin-usuarios aceita papel_vinculos[] com N escolas por papel | ✅ Implementado |
| 7. Gestor CRUD | Gestor da escola vincula usuário existente (CPF já cadastrado) | ⏳ Pendente |
| 8. Docs + Deprecação | Documentação atualizada, campo legado marcado | ✅ Este documento |
Como Funciona — Fluxos Detalhados
1. Login com Múltiplas Escolas
Usuário digita CPF → send-otp → WhatsApp → verify-otp
│
▼
Busca papéis em usuario_papeis
(com escola_nome via JOIN escolas)
│
▼
Verifica bloqueios para CADA escola:
- Status da escola (suspensa/encerrada?)
- Assinatura (trial expirado?)
│
▼
Agrupa perfis por UI:
coordenador+pedagogico+professor = "Coordenação"
escola = "Gestão"
etc.
│
▼
Chave de agrupamento: perfil_ui::escola_id
Ex: "Coordenação::uuid-A" e "Coordenação::uuid-B"
são cards DISTINTOS
│
▼
┌──────────────────────────────┐
│ 1 perfil único? Login direto │
│ N perfis? Tela de seleção │
└──────────────────────────────┘
│
▼
Usuário seleciona perfil
Frontend envia: { papel, escola_id }
│
▼
select-role valida par em usuario_papeis
Re-assina JWT com escola_id correto
Atualiza usuarios.escola_id (write-through)2. Troca de Perfil In-App (Role Switcher)
Usuário clica no avatar → Popover abre
│
▼
Lista combinações perfil+escola do JWT roles[]
Cada item mostra: "Coordenação" + "Escola Alpha" (subtexto)
│
▼
Usuário seleciona outro perfil
Frontend: invokeAction('select-role', 'select', { papel, escola_id })
│
▼
Backend valida → re-assina JWT → cookie atualizado
Frontend recarrega com novo contexto3. Admin Criando Usuário Multi-Escola
Admin abre formulário de criar usuário
│
▼
Seção "Vínculos Papel + Escola":
[+ Adicionar Vínculo]
┌──────────────────────────────────────┐
│ Papel: [Coordenador ▼] │
│ Escola: [Escola Alpha ▼] │
│ [Adicionar] │
└──────────────────────────────────────┘
Vínculos adicionados aparecem como badges:
🏷️ Coordenador — Escola Alpha [×]
🏷️ Diretor — Escola Beta [×]
🏷️ Administrador (sem escola) [×]
│
▼
Salvar → Backend recebe papel_vinculos[]:
[
{ papel_id: "uuid-coord", escola_id: "uuid-alpha" },
{ papel_id: "uuid-dir", escola_id: "uuid-beta" },
{ papel_id: "uuid-admin", escola_id: null }
]
│
▼
Backend cria registros em usuario_papeis
Seed de permissões por (escola_id, papel_id)
→ usuarios_escola_permissoes escopado por papel_id
→ usuarios_escola_sub_permissoes escopado por papel_id4. Bloqueios por Escola
Quando uma escola está suspensa ou com trial expirado, apenas os perfis naquela escola são bloqueados. Exemplo:
Usuário com:
- Coordenador na Escola A (ativa) → ✅ Liberado
- Coordenador na Escola B (suspensa) → 🔒 Bloqueado
- Administrador (sem escola) → ✅ Liberado
Na tela de seleção:
[Coordenação - Escola Alpha] ← selecionável
[Coordenação - Escola Beta] ← card desabilitado com mensagem
[Administrador] ← selecionávelArquitetura Técnica
Fonte da Verdade (SSOT)
ANTES (pré-Fev/2026): usuarios.escola_id (coluna única, 1:1)
AGORA: usuario_papeis.escola_id (tabela N:N)A tabela usuario_papeis tem constraint UNIQUE em (usuario_id, papel_id, escola_id) e é a fonte da verdade para vínculos usuário-escola.
Permissões Escopadas por Papel (Abr/2026)
Desde Abr/2026, as tabelas usuarios_escola_permissoes e usuarios_escola_sub_permissoes incluem papel_id como dimensão obrigatória:
ANTES: UNIQUE (usuario_id, escola_id, permissao)
AGORA: UNIQUE (usuario_id, escola_id, papel_id, permissao)Isso resolve o problema de colisão quando um usuário tem múltiplos papéis na mesma escola (ex: coordenador + diretor). Cada papel tem seu conjunto de permissões e sub-flags isolado.
Impacto no fluxo Multi-Escola:
- Ao trocar de papel via Role Switcher, o
/meresolve opapel_idcorreto e retorna menu/permissões isolados - Ao criar vínculo, o seed de permissões é escopado por
papel_id - Ao remover vínculo, apenas permissões daquele
papel_idsão removidas
JWT Claims (Atualizado)
{
"sub": "uuid-usuario",
"principal_role": "coordenador",
"escola_id": "uuid-escola-ativa",
"roles": [
{ "nome": "coordenador", "escola_id": "uuid-escola-A" },
{ "nome": "coordenador", "escola_id": "uuid-escola-B" },
{ "nome": "administrador", "escola_id": null }
]
}O escola_id raiz é o da escola ativa (selecionada). O array roles[] contém todos os vínculos.
RLS — Zero Alterações
As ~60 policies que usam auth.jwt() ->> 'escola_id' continuam funcionando sem alteração. O JWT sempre contém o escola_id correto para o contexto ativo.
Campo Legado usuarios.escola_id
- Status: Mantido por compatibilidade
- Atualizado: Via write-through em
select-role(reflete última escola ativa) - NÃO é SSOT: Não deve ser usado para derivar o JWT
- Deprecação futura: Pode ser removido quando todas as referências internas forem migradas para
usuario_papeis
Trigger de Validação
O trigger fn_validar_escola_usuario_papel foi atualizado para permitir vincular papéis a qualquer escola (não mais restrito ao usuarios.escola_id). Garante que:
- Papéis com escopo de escola têm
escola_idNOT NULL e válido - Papéis globais (admin/especialista) têm
escola_id = NULL
Arquivos Modificados
| Arquivo | Mudança |
|---|---|
supabase/functions/select-role/index.ts | Aceita { papel, escola_id }, valida contra usuario_papeis |
supabase/functions/verify-otp/index.ts | JWT escola_id de usuario_papeis, bloqueios multi-escola |
supabase/functions/me/index.ts | Bloqueios em batch, retorna escola_nome por papel |
supabase/functions/admin-usuarios/index.ts | CRUD com papel_vinculos[] |
src/components/login-unified.tsx | Agrupamento por perfil::escola_id, cards distintos |
src/components/role-switcher.tsx | Popover com perfil+escola, envia escola_id |
src/components/admin-usuarios.tsx | Formulário de vínculos papel+escola |
src/hooks/useAdminUsuarios.ts | Hook com papel_vinculos |
src/contexts/auth-context.tsx | Bloqueios tipados com escola_id |
src/types/auth.ts | escola_nome no tipo Papel |
Pendências (Etapa 7 — Gestor CRUD)
A Etapa 7 foi intencionalmente adiada. Ela trata do cenário onde o Gestor de uma escola cria um usuário cujo CPF já existe no sistema (vinculado a outra escola).
O que precisa ser implementado:
supabase/functions/gestao-usuarios-escola/index.ts:- Na action
create, antes de inserir emusuarios, verificar se o CPF já existe - Se existir: NÃO criar novo registro em
usuarios - Apenas adicionar novo registro em
usuario_papeiscomescola_iddo gestor - Seed de permissões em
usuarios_escola_permissoespara o novo vínculo - Retornar mensagem informativa: "Usuário já existia e foi vinculado à sua escola"
- Na action
src/components/usuarios-escola.tsx:- Exibir feedback quando usuário é vinculado (vs. criado do zero)
- Possível alerta: "Este CPF já está cadastrado em outra escola. Deseja vincular?"
Considerações de segurança:
- O gestor NÃO deve ver dados da outra escola
- O gestor NÃO deve alterar dados pessoais (nome, telefone) se o usuário já existe
- Apenas o vínculo
usuario_papeisé criado
Outras pendências menores:
- Testes E2E: Criar cenários de teste para login multi-escola (login → seleção → troca de perfil)
gestao-usuarios-escolaupdate/delete: Ao remover um usuário da escola, desativar apenas ousuario_papeisdessa escola (não o usuário inteiro)- Migração de dados: Se houver usuários existentes com vínculos implícitos em múltiplas escolas que não estão em
usuario_papeis, será necessário um script de migração one-time