Plano de Reestruturação por Papel + Sidebar Server-Driven + Cobertura Completa de Testes
Status: Aprovado (v2) — aguardando estabilização do MVP-1 para execução. Aprovação v1: 2026-03-17 Revisão v2: 2026-03-17 — Sidebar server-driven, feature flags, sem wrappers finos, fase 2 detalhada.
Contexto e Decisão
Prioridade atual: estabilidade MVP-1, suporte a clientes em produção, padronização. Esta refatoração será executada depois de fechar as funcionalidades pendentes. Este documento serve como guia de implementação futura.
1. Estrutura de Pastas — Organização por Papel
A regra é simples: cada papel é dono da sua pasta, cada feature vive dentro da pasta do papel que a usa.
src/components/
├── ui/ # Design system (shadcn) — inalterado
├── shared/ # Componentes cross-role
│ └── unified-sidebar.tsx # Sidebar única, server-driven
│
├── admin/ # Papel: administrador
│ ├── content.tsx # renderContent() do admin (fase 2)
│ ├── dashboard.tsx # ex admin-dashboard.tsx
│ ├── escolas/ # Feature: gestão de escolas
│ │ ├── lista.tsx # ex admin-escolas.tsx
│ │ └── detalhes.tsx # ex admin-escola-detalhes.tsx
│ ├── assinaturas/ # Feature: assinaturas + faturas
│ │ ├── lista.tsx # ex admin-assinaturas.tsx
│ │ ├── faturas.tsx # ex admin-faturas.tsx
│ │ └── faturas-modal.tsx # ex admin-faturas-modal.tsx
│ ├── usuarios.tsx # ex admin-usuarios.tsx
│ ├── monitoramento/ # Feature: logs, CRON, incidentes
│ │ ├── logs.tsx # ex admin-logs.tsx
│ │ ├── cron-monitor.tsx # ex admin-cron-monitor.tsx
│ │ ├── incidentes.tsx # ex admin-incidentes.tsx
│ │ └── mensagens-logs.tsx # ex admin-sms-logs.tsx (logs WhatsApp/Wasender)
│ ├── notificacao.tsx # ex admin-enviar-notificacao.tsx
│ └── index.ts # Barrel exports
│
├── especialista/ # Papel: especialista
│ ├── content.tsx # renderContent() do especialista (fase 2)
│ ├── banners.tsx # ex banners-especialista.tsx
│ ├── header-novidades.tsx # ex header-novidades-especialista.tsx
│ ├── olimpiadas/ # Feature: olimpíadas dados
│ │ ├── lista.tsx # ex olimpiadas-dados-especialista.tsx
│ │ └── detalhes.tsx # ex olimpiada-detalhes-especialista.tsx
│ ├── templates/ # Feature: templates mensagens
│ │ ├── lista.tsx # ex templates-olimpiadas-lista.tsx
│ │ ├── detalhes.tsx # ex templates-olimpiada-detalhes.tsx
│ │ └── edicao.tsx # ex template-edicao.tsx
│ ├── cursos/ # Feature: formação
│ │ ├── lista.tsx # ex cursos-especialista.tsx
│ │ └── gerenciar-videos.tsx # ex gerenciar-videos-curso.tsx
│ ├── tutoriais.tsx # ex tutoriais-especialista.tsx
│ ├── configuracoes.tsx # ex configuracoes-sistema-especialista.tsx
│ └── index.ts
│
├── coordenador/ # Papel: coordenador
│ ├── content.tsx # renderContent() do coordenador (fase 2)
│ ├── agenda/ # ✅ JÁ MIGRADO — ex dashboard-coordenador.tsx (7 arquivos)
│ ├── olimpiadas.tsx # ex olimpiadas.tsx
│ ├── calendario.tsx # ex calendario-olimpico.tsx
│ ├── resultados/ # Feature: resultados + premiação
│ │ ├── lista.tsx # ex resultados.tsx
│ │ ├── detalhes.tsx # ex resultados-olimpiada-detalhes.tsx
│ │ ├── inserir-dialog.tsx # ex resultados-inserir-dialog.tsx
│ │ ├── premiacao-modal.tsx # ex resultados-premiacao-manual-modal.tsx
│ │ └── selecao-metodo.tsx # ex resultados-selecao-metodo-modal.tsx
│ ├── inscricoes.tsx # ex inscricoes.tsx
│ ├── alunos/ # Feature: gestão de alunos
│ │ ├── lista.tsx # ex alunos-escola.tsx
│ │ ├── importacao/ # Sub-feature — já existe como pasta
│ │ └── transferencias/ # ex transferencia-alunos-*.tsx
│ ├── turmas/ # já existe como pasta
│ ├── comunicacao/ # Feature: mensagens
│ │ ├── lista.tsx # ex comunicacao.tsx
│ │ ├── mensagem-modal.tsx
│ │ └── rascunho-modal.tsx
│ ├── videos.tsx # ex videos.tsx
│ ├── mural-olimpico/ # já existe como pasta — mover para cá
│ ├── controle-aplicacoes/ # Feature: aplicações
│ │ ├── lista.tsx # ex controle-aplicacoes.tsx
│ │ ├── novo.tsx # ex controle-aplicacoes-novo.tsx
│ │ └── config-inscricao.tsx # ex config-arquivo-inscricao.tsx
│ └── index.ts
│
├── escola/ # Papel: escola (gestão)
│ ├── content.tsx # renderContent() da escola (fase 2)
│ ├── dashboard.tsx # ex dashboard-escola.tsx
│ ├── usuarios.tsx # ex usuarios-escola.tsx
│ ├── pagamentos.tsx # ex pagamentos-escola.tsx
│ ├── configuracoes.tsx # ex configuracoes-escola.tsx
│ ├── portal-config.tsx # ex portal-config-escola.tsx
│ └── index.ts
│
├── diretor/ # já existe — inalterado
│ ├── content.tsx # renderContent() do diretor (fase 2)
│ ├── painel-geral-diretor.tsx
│ ├── uso-plataforma-diretor.tsx
│ ├── projeto-olimpico-diretor.tsx
│ ├── financeiro-diretor.tsx
│ └── index.ts
│
├── portal/ # Portal aluno/responsável (auth separada)
│ └── (já gerenciado em pages/portal/)
│
└── auth/ # Login, role-switcher
├── login-unified.tsx
├── login-especialista.tsx
├── role-switcher.tsx
└── index.tsRegra de ouro
Se o componente é exclusivo de um papel → vive dentro da pasta do papel. Se é compartilhado entre papéis → vive em
shared/. Se é design system → vive emui/.
O que muda para os usuários em produção
Nada. Esta é uma refatoração interna de organização de arquivos. Nenhuma funcionalidade, tela, ou comportamento muda.
2. Sidebar Unificada — Server-Driven
Problema atual
Existem 4 implementações separadas da sidebar com código duplicado:
| Arquivo | Papel(is) | Linhas |
|---|---|---|
sidebar.tsx | escola + coordenador (compartilhada via prop) | 180 |
sidebar-admin.tsx | administrador | 139 |
sidebar-especialista.tsx | especialista | 143 |
diretor/sidebar-diretor.tsx | diretor | 135 |
Todas compartilham: estado collapse/expand, animação de texto, perfil com avatar/badge, logo, scroll horizontal. A diferença é só a lista de menuItems.
Decisão: Sem wrappers por papel
A sidebar não resolve permissões, não filtra nada, não conhece flags. Ela recebe items prontos do backend via useMyPermissions().
Arquitetura
┌──────────────────────────────────────────────────────┐
│ Edge Function: user-permissions │
│ │
│ 1. Lê usuario_papeis → principal_role + escola_id │
│ 2. Lê usuarios_escola_permissoes → permissionKeys │
│ 3. Lê feature_flags + canary → flags ativas escola │
│ 4. sections-registry (server-side copy) → todos os │
│ items do papel │
│ 5. Intersecção: permissão ∩ flag ativa = items │
│ 6. Retorna: { role, permissoes[], menuItems[] } │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ useMyPermissions() (React Query, 5min staleTime) │
│ │
│ Retorna: { role, permissoes, menuItems, isLoading } │
└──────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ UnifiedSidebar │
│ │
│ Recebe menuItems prontos, renderiza. │
│ Mapeia iconKey string → componente Lucide. │
│ Zero lógica de permissão/flag. │
└──────────────────────────────────────────────────────┘Interface da Sidebar
// src/components/shared/unified-sidebar.tsx
interface MenuItem {
id: string; // ex: "agenda", "mural", "resultados"
label: string; // ex: "Agenda"
iconKey: string; // ex: "LayoutDashboard" → mapeia para componente Lucide
}
interface UnifiedSidebarProps {
activeSection: string;
onSectionChange: (section: string) => void;
username: string;
role: string;
menuItems: MenuItem[]; // ← 100% resolvido server-side
escolaNome?: string;
}Consumo no App.tsx
const { menuItems, role } = useMyPermissions();
<UnifiedSidebar
activeSection={activeSection}
onSectionChange={handleSectionChange}
username={username}
role={role}
menuItems={menuItems}
escolaNome={escolaNome}
/>iconMap — único ponto de mapeamento no frontend
// src/lib/icon-map.ts
import { LayoutDashboard, Newspaper, BarChart3, ... } from 'lucide-react';
export const iconMap: Record<string, LucideIcon> = {
LayoutDashboard,
Newspaper,
BarChart3,
// ...
};A sidebar usa iconMap[item.iconKey] para renderizar. O frontend não sabe quais items existem — só renderiza o que veio do backend.
3. Feature Flags — Controle Completo Server-Side
Objetivo
Ligar/desligar funcionalidades sem deploy, com granularidade por escola, região ou global. Tudo controlado pelo admin via UI.
Tabelas envolvidas
| Tabela | Função |
|---|---|
feature_flags | Registro de cada flag (slug, ativa_global, descricao) |
canary_groups | Grupos de escolas (ex: "Escolas SP", "Rede ABC") |
canary_group_escolas | Vínculo escola ↔ grupo |
feature_flag_canary | Vínculo flag ↔ grupo (ativa flag para grupo) |
usuarios_escola_permissoes | Permissão individual por usuário |
Fluxo de resolução (dentro de user-permissions)
1. Login → select-role → JWT com { usuario_id, escola_id, principal_role }
2. Frontend chama user-permissions (GET my_permissions)
3. Edge Function resolve TUDO:
a) Busca permissões do usuário (usuarios_escola_permissoes)
b) Busca feature flags:
- Se flag.ativa_global = true → ativa para todos
- Senão, verifica se escola está em canary_group com a flag ativa
c) Carrega sections-registry do papel
d) Para cada section:
- Tem permissão? (ou papel sem permissões como admin/especialista)
- Feature flag ativa para escola? (se section tem flag vinculada)
- Ambos verdadeiros → inclui no menuItems
e) Retorna { role, permissoes[], menuItems[] }
4. Frontend recebe e renderiza. Não filtra nada.Exemplo concreto
Escola A tem flag mural_v2 ativa. Escola B não.
// Coordenador Escola A:
{
"role": "coordenador",
"permissoes": ["agenda", "mural", "resultados"],
"menuItems": [
{ "id": "agenda", "label": "Agenda", "iconKey": "LayoutDashboard" },
{ "id": "mural", "label": "Mural Olímpico", "iconKey": "Newspaper" },
{ "id": "resultados", "label": "Resultados", "iconKey": "BarChart3" }
]
}
// Coordenador Escola B (sem flag mural_v2):
{
"role": "coordenador",
"permissoes": ["agenda", "resultados"],
"menuItems": [
{ "id": "agenda", "label": "Agenda", "iconKey": "LayoutDashboard" },
{ "id": "resultados", "label": "Resultados", "iconKey": "BarChart3" }
]
}O frontend da Escola B nunca sabe que mural existe. Switch on/off puro no backend. Sem afetar estado do usuário.
Granularidade de controle (tudo via UI admin)
| Nível | Controle | Mecanismo |
|---|---|---|
| Global | Liga/desliga para todos | feature_flags.ativa_global = true |
| Por escola | Liga para escolas específicas | canary_group_escolas + feature_flag_canary |
| Por região | Grupo de escolas por região | canary_groups (ex: "Escolas SP") |
| Por rede/mantenedora | Grupo de escolas da mesma rede | canary_groups (ex: "Rede ABC") |
| Por usuário | Permissão individual | usuarios_escola_permissoes |
O admin gerencia tudo via painel na UI. A resolução final acontece na Edge Function user-permissions.
Integração com CANARY_RELEASE_SYSTEM.md
O plano de canary release (docs/plans/CANARY_RELEASE_SYSTEM.md) define as tabelas e fluxo de rollout progressivo. Este plano integra o canary com o sections-registry.ts e a sidebar, completando o ciclo:
Admin UI → feature_flags + canary_groups → user-permissions resolve → menuItems → sidebar renderiza4. Fase 2 — Extração do renderContent() (App.tsx)
Problema atual
O App.tsx (~973 linhas) contém um renderContent() gigante com switches aninhados por papel:
// Hoje:
if (userProfile === "administrador") {
switch(activeSection) {
case "admin-dashboard": return <AdminDashboard />;
case "admin-escolas": return <AdminEscolas />;
// ... ~15 cases
}
}
if (userProfile === "coordenacao") {
switch(activeSection) {
case "painel_controle": return <DashboardCoordenador />;
// ... ~20 cases
}
}
// etc.Solução (fase 2, após pastas por papel existirem)
Extrair cada bloco para um componente <XContent> dentro da pasta do papel:
// App.tsx simplificado (~300 linhas)
const renderContent = () => {
switch (userProfile) {
case "administrador": return <AdminContent activeSection={activeSection} />;
case "especialista": return <EspecialistaContent activeSection={activeSection} />;
case "gestao": return <EscolaContent activeSection={activeSection} />;
case "coordenacao": return <CoordenadorContent activeSection={activeSection} />;
case "diretor": return <DiretorContent activeSection={activeSection} />;
}
};// src/components/coordenador/content.tsx
export function CoordenadorContent({ activeSection }: { activeSection: string }) {
switch (activeSection) {
case "agenda": return <Agenda />; // src/components/agenda/ (já migrado)
case "mural": return <MuralOlimpico />;
case "resultados": return <Resultados />;
// ...
default: return <Agenda />;
}
}Proteção via feature flags
Com feature flags server-side, se um menuItem não veio na resposta, o activeSection nunca será setado para aquele valor (não existe botão na sidebar), e o componente nunca renderiza. Zero lógica condicional de flags no renderContent().
Por que é fase 2?
Depende das pastas por papel existirem (fase 1) para que os imports façam sentido. Executar após a migração de arquivos.
5. Cobertura Completa de Testes — Novo Padrão
Política a partir de agora
Toda feature que vai para produção deve ter:
| Camada | Ferramenta | O que cobre | Onde vive |
|---|---|---|---|
| Unitário | Vitest | Lógica de negócio, helpers, transforms, validações | __tests__/ dentro da feature folder |
| Edge Function | Deno test | CORS, auth, RBAC, contratos de request/response, erros | supabase/functions/<fn>/index.test.ts |
| E2E | Playwright | Jornadas de usuário ponta-a-ponta por papel | e2e/<dominio>.spec.ts |
Processo para a refatoração de pastas
Para cada domínio movido:
- Inventário de testes existentes — listar o que já existe
- Escrever testes faltantes ANTES de mover — garantir baseline funcional completa
- Rodar suite completa do projeto —
vitest run+deno test+playwright test - Mover arquivos — criar pasta, atualizar imports, barrel export
- Rodar suite completa novamente — zero regressões
- Um domínio por iteração — nunca misturar dois
Testes que faltam hoje (inventário parcial, precisa completar)
| Domínio | Unit | Edge Fn | E2E | Status |
|---|---|---|---|---|
| mural-olimpico | ✅ 82 testes | ✅ 20 testes | ✅ mural-coordenador.spec.ts | Completo |
| olimpiada-detalhes | ✅ vitest | ✅ deno | ✅ olimpiada-detalhes.spec.ts | Completo |
| resultados | ❌ | ❌ gestao-resultados | ❌ | Prioridade 1 |
| alunos | ❌ | ❌ gestao-alunos | ❌ | Prioridade 1 |
| comunicacao | ❌ | ❌ comunicacao-escola | ❌ | Prioridade 2 |
| inscricoes | ❌ | ❌ inscricoes-olimpiada | ❌ | Prioridade 2 |
| admin-escolas | ❌ | ❌ admin-escolas | ❌ | Prioridade 2 |
| usuarios-escola | ❌ | ❌ gestao-usuarios-escola | ❌ | Prioridade 2 |
| portal-aluno | ✅ visibility | ❌ portal-escola | ✅ portal-aluno.spec.ts | Quase completo |
6. Ordem de Execução Recomendada
Após fechar as funcionalidades do MVP-1:
| Fase | Domínio | Risco | Justificativa |
|---|---|---|---|
| 0 | shared/unified-sidebar.tsx + user-permissions retornando menuItems | Médio | Base para tudo; precisa de mudança no backend |
| 1 | admin/ | Baixo | Pouco acoplamento com outros papéis, poucos usuários admin |
| 2 | especialista/ | Baixo | Mesmo raciocínio, papel isolado |
| 3 | auth/ | Baixo | 3 arquivos, sem lógica de negócio complexa |
| 4 | escola/ | Médio | Extrair modo gestao da sidebar.tsx compartilhada |
| 5 | coordenador/ | Alto | Maior volume de features, mais arquivos, mais acoplamento |
| 6 | portal/ | Baixo | Já isolado em pages/portal/ |
| 7 | Fase 2: content.tsx por papel + slim App.tsx | Médio | Depende de todas as pastas existirem |
7. Lacunas e Riscos Identificados
Acoplamento sidebar.tsx ↔ dois papéis
A sidebar.tsx atual serve escola e coordenador via prop userProfile. Na refatoração, ela é eliminada — substituída pela UnifiedSidebar que recebe menuItems do backend. Não há split, não há wrapper.
alunos-escola.tsx — 3.455 linhas
Este arquivo precisa de split antes de mover para coordenador/alunos/. É o maior monolito do projeto e impossível de testar adequadamente sem decomposição.
Hooks seguem flat
Os hooks (src/hooks/) não precisam mudar de estrutura agora. Eles já têm prefixos claros e não são exclusivos de um papel. Mover hooks para dentro de pastas de papel criaria acoplamento artificial.
Backend não muda (estrutura)
As Edge Functions (supabase/functions/) já seguem organização por papel com prefixos. A única mudança é na user-permissions para retornar menuItems além de permissoes.
sections-registry: cópia server-side
O sections-registry.ts hoje vive no frontend. Para a sidebar server-driven, uma cópia ou equivalente precisa existir no backend (dentro de _shared/ ou inline na user-permissions). A fonte de verdade de quais sections existem por papel vive no backend.
Feature flags: tabelas necessárias
As tabelas feature_flags, canary_groups, canary_group_escolas e feature_flag_canary precisam ser criadas via migration antes da fase 0. O plano completo está em docs/plans/CANARY_RELEASE_SYSTEM.md.
Resumo
| Decisão | Valor |
|---|---|
| Organização | Por papel → feature dentro do papel |
| Sidebar | Componente único, server-driven, sem wrappers por papel |
| Permissões/Flags | 100% resolvidos no backend, frontend só renderiza |
| Feature Flags | Granular: global, por escola, por região, por usuário |
| Fase 2 | Extração do renderContent() em content.tsx por papel |
| Testes | Cobertura completa obrigatória antes de qualquer move |
| Timing | Após MVP-1 estabilizado |
| Processo | Um papel por iteração, testes antes e depois |
| Impacto em produção | Zero — refatoração interna |