Padrões de Código — Plataforma OLP
1. Regra de Ouro: Edge Function vs Supabase Client
NUNCA usar Supabase client direto para:
- Dados que dependem de
escola_iddo usuário logado - Dados que dependem de
principal_role - Qualquer dado com escopo de tenant/escola
- Operações que precisem de log de auditoria
O Supabase client do frontend usa anon key que NÃO tem acesso aos claims do JWT customizado da OLP.
Quando DEVE usar Edge Function
| Cenário | Motivo |
|---|---|
Dados vinculados a escola_id | Escopo de tenant |
Verificação de principal_role | Autorização |
| CREATE / UPDATE / DELETE | Log obrigatório |
| Cross-escola (admin) | Bypass RLS controlado |
Quando PODE usar Supabase Client direto
| Cenário | Exemplo |
|---|---|
| Dados públicos sem escopo | banners_login ativos |
| Upload de arquivos | supabase.storage.from('bucket').upload() |
| Queries públicas read-only | Séries escolares |
Diagrama de Decisão
Dados com escopo de escola/tenant?
│
├─ SIM → Edge Function obrigatória
│ + filtro manual por escola_id
│ + log obrigatório para escrita
│
└─ NÃO → Dados públicos?
│
├─ SIM → Supabase client OK
└─ NÃO → Edge Function2. Helper Centralizado (src/lib/edge-function.ts)
TODAS as chamadas a Edge Functions devem usar este helper. Ele:
- Centraliza a URL base (suporta Gateway/Worker via env)
- Envia
credentials: 'include'automaticamente (cookies HttpOnly) - Padroniza o formato de resposta
Funções Disponíveis
| Função | Formato do Body | Uso |
|---|---|---|
invokeAction(fn, action, params) | { action, params: {...} } | ⭐ Mais comum — CRUD padrão |
invokeActionFlat(fn, action, params) | { action, ...params } | Params no nível raiz |
invokeEdge(fn, body) | Body livre | Customizado |
invokeUploadBase64(fn, action, file, params) | Base64 em JSON | Upload (preferido — mantém cookies) |
invokeUploadFormData(fn, formData) | FormData | Upload (problemas com cookies cross-origin) |
Interface de Resposta: EdgeResponse<T>
interface EdgeResponse<T = any> {
success: boolean;
data?: T; // Dados retornados (tipado)
message?: string; // Mensagem de erro/sucesso
error?: string; // Detalhes técnicos do erro
[key: string]: any; // Campos extras (pagination, count, etc.)
}Padrão Obrigatório de Consumo
// ✅ CORRETO
const result = await invokeAction<MeuTipo[]>('minha-function', 'list', { filtro: 'x' });
if (!result.success) {
throw new Error(result.message || 'Erro desconhecido');
}
setItems(result.data || []);
// ❌ ERRADO — Não verificar success
const result = await invokeAction('minha-function', 'list');
setItems(result); // result é EdgeResponse, não MeuTipo[]!
// ❌ ERRADO — Acessar campo inexistente
if (result.success && result.items) { ... } // items não existe, use result.data!
// ❌ ERRADO — fetch manual
const response = await fetch(`${url}/functions/v1/fn`, { ... }); // NÃO FAZER!3. Toast Obrigatório
import { olpToast } from '@/components/ui/use-olp-toast';
import { getUserFriendlyError } from '@/lib/error-helpers';
// ✅ CORRETO — Sucesso
olpToast.success('Título', { description: 'Descrição' });
olpToast.info('Título', { description: 'Descrição' });
// ✅ CORRETO — Erro (SEMPRE sanitizar)
olpToast.error('Erro ao salvar', { description: getUserFriendlyError(error) });
// ❌ PROIBIDO — Sonner direto
alert('...');
toast('...'); // sonner direto — usar olpToast
// ❌ PROIBIDO — Erro técnico cru no toast
olpToast.error('Erro', { description: error.message }); // expõe SQL/stack ao usuárioRegra: Sanitização de Erros em Toasts
TODOS os onError de mutations e blocos catch que exibem toast DEVEM usar getUserFriendlyError() de @/lib/error-helpers.ts.
// ✅ CORRETO — mutation com sanitização
const mutation = useMutation({
mutationFn: async (data) => { ... },
onError: (error: Error) => {
olpToast.error('Erro ao salvar', { description: getUserFriendlyError(error) });
},
});
// ✅ CORRETO — catch com sanitização
try {
await operacao();
} catch (err) {
olpToast.error('Erro', { description: getUserFriendlyError(err) });
}Modelo de referência: useGestaoResultados.ts, useImportacaoResultados.ts
4. Logging Obrigatório
Quando Logar
| Operação | Logar? |
|---|---|
| CREATE | ✅ Sempre |
| UPDATE | ✅ Sempre (incluir diff) |
| DELETE / Soft Delete | ✅ Sempre |
| LOGIN / LOGOUT | ✅ Sempre |
| SELECT / READ | ❌ Não (trade-off de performance) |
Formato da Ação
<modulo>.<operacao> (lowercase)Exemplos: login.success, escola.create, usuario.update, banner.soft_delete
Chamada Obrigatória
await registrarLog({
usuarioId: user.id,
nomeUsuario: user.nome_completo,
papelPrincipal: user.principal_role,
ip: extractIP(req),
acao: "modulo.operacao",
detalhes: {
tipo: "create" | "update" | "soft_delete",
entidade: "nome_tabela",
entidadeId: "uuid",
resumo: { /* campos principais */ }, // para CREATE
alteracoes: [/* diff antes/depois */], // para UPDATE
},
req: req, // ← OBRIGATÓRIO para geolocalização via Cloudflare
});CRÍTICO: O parâmetro
req: reqé obrigatório. Sem ele, o sistema faz fallback para FreeIPAPI que resolve o IP do Worker, resultando em geolocalização incorreta.
Helper de Diff para UPDATE
import { gerarAlteracoes } from '../_shared/diff-helper.ts';
const { data: antes } = await supabase.from("tabela").select("*").eq("id", id).single();
const { data: depois } = await supabase.from("tabela").update(dados).eq("id", id).select().single();
const alteracoes = gerarAlteracoes(antes, depois, ["id", "criado_em", "atualizado_em"]);5. Nomenclatura de Edge Functions
| Prefixo | Papel | Exemplo |
|---|---|---|
admin-* | Administrador | admin-escolas, admin-usuarios |
especialista-* | Especialista | especialista-olimpiadas, especialista-banners |
escola-* | Usuário escola | escola-dados, escola-dashboard |
gestao-* | Gestão interna da escola | gestao-alunos, gestao-turmas |
coordenador-* | Coordenador | coordenador-olimpiadas |
diretor-* | Diretor | diretor-dashboard |
portal-* | Portal aluno/responsável | portal-escola |
6. Padrão de Hook React (React Query — Obrigatório)
IMPORTANTE: Hooks que listam dados, fazem CRUD, ou servem dashboards DEVEM usar
useQuery/useMutationdo TanStack React Query. Veja critérios completos em REACT_QUERY_CACHE.md.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/contexts/auth-context';
import { invokeAction } from '@/lib/edge-function';
import { olpToast } from '@/components/ui/use-olp-toast';
import { getUserFriendlyError } from '@/lib/error-helpers';
export function useMeuHook() {
const { isAuthenticated, papelPrincipal } = useAuth();
const queryClient = useQueryClient();
const { data: items = [], isLoading } = useQuery<MeuTipo[]>({
queryKey: ['meu-modulo'],
queryFn: async () => {
const result = await invokeAction<MeuTipo[]>('minha-function', 'list');
if (!result.success) throw new Error(result.message);
return result.data || [];
},
enabled: isAuthenticated && papelPrincipal === 'coordenador',
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
const createMutation = useMutation({
mutationFn: async (data: CreateData) => {
const result = await invokeAction('minha-function', 'create', data);
if (!result.success) throw new Error(result.message);
return result.data;
},
onSuccess: () => {
olpToast.success('Criado', { description: 'Operação realizada.' });
queryClient.invalidateQueries({ queryKey: ['meu-modulo'] });
},
onError: (error: Error) => {
olpToast.error('Erro ao criar', { description: getUserFriendlyError(error) });
},
});
return { items, isLoading, criar: createMutation.mutateAsync };
}Template completo com mutations de update/delete: NEW_HOOK.md
Checklist para Novo Hook
- [ ] Importa de
@/lib/edge-function(nunca fetch direto) - [ ] Usa
useQuerypara leitura (nãouseState+useEffect) - [ ] Usa
useMutationpara escrita (não funções manuais) - [ ] Especifica tipo genérico
<T>na chamada - [ ] Verifica
result.successantes de usarresult.data - [ ]
enabledcondicionado aisAuthenticated+ papel - [ ]
invalidateQueriesnoonSuccessde cada mutation - [ ]
getUserFriendlyError(error)em TODOS osonErrorecatch - [ ] Toast via
olpToast(nuncatoastdo Sonner direto)
7. Diagnóstico de Erros Comuns
| Erro | Causa Provável | Solução |
|---|---|---|
400 Bad Request + "Ação não reconhecida" | Action inexistente no backend | Verificar nome da action |
401 Não autenticado | Cookie não enviado | Verificar credentials: 'include' e CORS |
data é undefined | Não verificou success | Adicionar if (!result.success) |
| HTTP 406 | .single() retornou 0 rows (RLS bloqueou) | Usar Edge Function com service_role |
| CORS error em preflight | Origin não permitida | Adicionar em ALLOWED_ORIGINS |
| Dados de outra escola | Não filtrou por escola_id | Adicionar .eq("escola_id", user.escola_id) |
| Toast genérico | Não exibe message do backend | Usar result.message no toast |
8. Áreas Sensíveis — Não Alterar
Sem instrução explícita:
src/components/ui/*(biblioteca shadcn)src/App.tsx/src/main.tsx(estrutura global)- Arquivos
*.config.*(package.json, tsconfig, vite, tailwind, eslint)
Se necessário:
- Apenas ESTENDER (adicionar dependência, alias, script)
- Nunca reescrever ou reformatar
9. Mensagens — Templates Centralizados
Arquivo: supabase/functions/_shared/sms-templates.ts
Todas as mensagens enviadas pelo sistema (OTP, alertas críticos, cobrança) são geradas por este helper. O envio é feito via WhatsApp (Wasender) com fallback para SMS. Para alterar qualquer mensagem, edite apenas este arquivo.
Uso Obrigatório
import { gerarMensagemSMS } from '../_shared/sms-templates.ts';
import { enviarMensagemComLog } from '../_shared/wasender-whatsapp.ts';
// Gerar texto da mensagem
const mensagem = gerarMensagemSMS('otp_sistema', { otp });
// Enviar via helper centralizado (WhatsApp via Wasender)
await enviarMensagemComLog({ to: telefone, message: mensagem, ... });Tipos Disponíveis
| Tipo | Contexto | Parâmetros |
|---|---|---|
otp_sistema | Login admin | { otp } |
otp_portal | Portal aluno/responsável | { otp } |
alerta_critico_cron | Cron jobs com falha | { jobName, tentativas, erro } |
fatura_gerada | Faturamento | { numeroFatura, valor, diaVencimento, linkPagamento } |
lembrete_fatura | Faturamento D-5 | { numeroFatura, valor, dataVencimento, linkPagamento } |
alerta_sessao_whatsapp | Monitoramento | { status, sessaoId } |
solicitacao_plano | Solicitação escola | { escola, plano, periodo, valor } |
Regras
- NUNCA construir strings de mensagem inline nas Edge Functions
- SEMPRE importar
gerarMensagemSMSdo helper compartilhado - SEMPRE enviar via
enviarMensagemComLogde_shared/wasender-whatsapp.ts(nunca API externa direta) - Para adicionar nova mensagem: adicionar tipo + interface + case no switch
10. Configuração de Gateway (Produção)
# .env (se usar Cloudflare Worker como gateway)
VITE_USE_WORKER=true
VITE_WORKER_URL=https://gateway.olp.digitalSe não configurado, usa Supabase diretamente.
11. Console Logging
| Método | Uso | Visibilidade em Produção |
|---|---|---|
console.log() | PROIBIDO em produção | ❌ Sempre visível — polui o console |
console.debug() | Diagnóstico (debug local) | ✅ Só aparece com nível "Verbose" no DevTools |
console.warn() | Situações inesperadas não-críticas | ✅ Visível por padrão |
console.error() | Erros que precisam de atenção | ✅ Visível por padrão |
Regras
- NUNCA usar
console.log()em código que será commitado — useconsole.debug()para informações de diagnóstico console.warn()econsole.error()são essenciais e NÃO devem ser removidos- Em
App.tsx, logs protegidos porimport.meta.env.DEVsão aceitáveis - Edge Functions (
supabase/functions/) não são afetadas — logs no Deno não poluem o console do browser
12. Importação/Exportação de Planilhas (src/lib/xlsx-utils.ts)
Biblioteca
O projeto usa ExcelJS (v4.4+) para leitura e escrita de planilhas. A biblioteca anterior (SheetJS/xlsx) foi removida.
Formatos Suportados
| Formato | Leitura | Escrita |
|---|---|---|
.xlsx | ✅ Via workbook.xlsx.load() | ✅ Via workbook.xlsx.writeBuffer() |
.csv | ✅ Via parser manual centralizado | ❌ (não necessário) |
.xls | ❌ Não suportado (formato legado) | ❌ |
Helpers Centralizados
| Função | Uso |
|---|---|
lerExcelComoArray(buffer, fileName?) | Retorna any[][] — detecção automática XLSX/CSV |
lerExcelComoObjetos(buffer, fileName?) | Retorna Record<string, any>[] com headers como chaves |
downloadExcelBuffer(buffer, nome) | Download de arquivo .xlsx |
gerarModeloImportacaoAlunos() | Gera modelo .xlsx para importação |
Detecção Automática de CSV
Quando fileName termina em .csv:
- O
ArrayBufferé decodificado como UTF-8 - O delimitador é detectado automaticamente (
,vs;) viadetectarDelimitadorCSV() - O parser manual (
parseCSV) trata campos entre aspas, aspas escapadas (""), e CRLF/LF - Retorna a mesma estrutura
any[][]que o parser XLSX
Nota: O ExcelJS
workbook.csv.read()usa streams Node.js incompatíveis com o browser. Por isso, o CSV é parseado manualmente no frontend.
Regras
- SEMPRE passar
fileNameao chamarlerExcelComoArray/lerExcelComoObjetospara habilitar detecção de formato - NUNCA usar
workbook.csv.read()no frontend (incompatível com browser) - Componentes de upload devem usar
accept=".xlsx,.csv"e texto "Formatos aceitos: XLSX, CSV" - Exportações sempre geram
.xlsx(formato mais rico e universal)
Testes
27 testes unitários em src/lib/__tests__/xlsx-utils.test.ts cobrem:
- Leitura XLSX (array e objetos)
- Leitura CSV (vírgula e ponto-e-vírgula)
- Caracteres especiais (acentos BR)
- Campos com aspas e quebras de linha
- Geração de modelos e downloads
13. Query Chunking — .in() com Arrays Grandes
Problema
O PostgREST/Supabase converte .in("coluna", ids) em parâmetros de URL. Com arrays grandes (>100 UUIDs), a URL pode exceder o limite do servidor (~8KB), causando falha silenciosa onde a query retorna vazio.
Impacto real: Escola com 652 inscrições tinha todos os resultados zerados porque a query .in("inscricao_id", [652 UUIDs]) falhava.
Solução: queryInChunks
Helper inline (definido dentro da Edge Function que precisa) que divide arrays em lotes de 100:
async function queryInChunks<T>(
supabase: any,
table: string,
column: string,
ids: string[],
selectFields: string,
extraFilters?: (q: any) => any,
chunkSize = 100
): Promise<T[]> {
if (ids.length === 0) return [];
if (ids.length <= chunkSize) {
let q = supabase.from(table).select(selectFields).in(column, ids);
if (extraFilters) q = extraFilters(q);
const { data } = await q;
return data || [];
}
const results: T[] = [];
for (let i = 0; i < ids.length; i += chunkSize) {
const chunk = ids.slice(i, i + chunkSize);
let q = supabase.from(table).select(selectFields).in(column, chunk);
if (extraFilters) q = extraFilters(q);
const { data } = await q;
if (data) results.push(...data);
}
return results;
}Quando Usar
| Cenário | Ação |
|---|---|
.in() com array de até 100 IDs | Chamada normal (sem chunks) |
.in() com array que pode exceder 100 | Usar queryInChunks |
| Queries de listagem com filtro por inscrições/alunos | Sempre usar chunks (volume imprevisível) |
Onde já está implementado
gestao-resultados/index.ts— açõeslist,list_by_olimpiada,stats,set_premiacoes_manual,recomputeFaseForOlimpiada
Regra
TODA Edge Function que faz .in() com arrays de tamanho variável (dependente de dados da escola) DEVE usar queryInChunks ou validar que o array não excederá 100 elementos.
14. Importação de Resultados — Arquitetura
Fluxo
- Frontend (
useImportacaoResultados): Upload XLSX → detecção dinâmica de colunas → preview → confirmação - Backend (
gestao-resultadosactionstart_import_session): Cria sessão emimportacao_resultados_sessoes→EdgeRuntime.waitUntilpara processamento background - Processamento: Batches de 50 resultados. Usa matrícula como chave. Auto-inscreve alunos sem inscrição (status
confirmada). - Pós-processamento:
recomputeFaseForOlimpiadarecalcula classificações e premiações uma vez ao final. - Frontend polling:
get_import_session_statusa cada 2s. Detecção de stale (30 ciclos sem progresso).
Detecção Dinâmica de Colunas
// Regex fuzzy matching para colunas comuns
const MATRICULA_PATTERNS = /matr[ií]cula|enrollment|registration/i;
const PONTUACAO_PATTERNS = /pontua[çc][aã]o|nota|score|points|acertos/i;Regra de Negócio
- Classificações e premiações NÃO são automáticas na importação
- Devem ser definidas separadamente via nota de corte ou inserção manual pelo coordenador
15. Importação Inteligente de Alunos — Arquitetura
Fluxo
- Upload: Aceita
.xlsxe.csvvia ExcelJS - Mapeamento: Headers do arquivo → campos do sistema (com auto-detecção)
- Detecção de turma: Coluna de turma/série detectada automaticamente via patterns (
turma,classe,sala,série) - Agrupamento: Alunos agrupados por turma com matching automático contra turmas existentes
- Validação de duplicidades: Verifica CPF, matrícula e nome completo contra alunos existentes da escola
- Conflitos: Modal de resolução com opções: ignorar, sobrescrever, editar
- Processamento background: Batches de 50 via
EdgeRuntime.waitUntil - Resiliência: ID da sessão em
localStorage(reconexão após F5), dados do wizard emsessionStorage
Componentes Frontend
| Componente | Responsabilidade |
|---|---|
importacao-alunos/index.tsx | Wizard principal (upload → mapping → preview → resultado) |
TurmaPreviewCard.tsx | Preview de alunos agrupados por turma |
ModalConflitos.tsx | Resolução de duplicidades |
CardConflito.tsx | Card individual de conflito |
ModalCriarTurma.tsx | Criar turma durante importação |
ModalEditarAluno.tsx | Editar dados de aluno antes de importar |
turma-matching.ts | Lógica de matching turma arquivo ↔ turma sistema |
16. Padrão de Diretório por Feature
Componentes com mais de ~400 linhas ou 3+ subcomponentes devem ser decompostos em diretórios.
Estrutura obrigatória
src/components/<dominio>/
├── index.tsx # Orquestrador — importa hooks, distribui estado via props
├── helpers.ts # Tipos, interfaces, funções puras (sem React)
├── <dominio>-*.tsx # Subcomponentes com prefixo do domínio
└── __tests__/ # Testes unitários (Vitest)Regras
index.tsxé o único export público do diretório- Hooks de dados (React Query) são chamados no
index.tsx, não nos filhos - Subcomponentes recebem dados e callbacks via props tipadas (sem
any) - Estados de UI locais (modais, formulários) ficam no subcomponente que os usa
helpers.tsnão importa React — apenas tipos e funções puras- Nomes de arquivo usam prefixo do domínio para evitar colisões globais
Exemplos aprovados
| Diretório | Arquivos | Origem |
|---|---|---|
src/components/agenda/ | 7 | Split de dashboard-coordenador.tsx (1410 linhas) |
src/components/coordenador/resultados/ | 8 | Split de resultados do coordenador |
src/components/mural-olimpico/ | 15+ | Mural Olímpico completo |
src/components/importacao-alunos/ | 7 | Importação inteligente de alunos |
Quando NÃO usar
- Componentes simples com <400 linhas e sem subcomponentes
- Componentes de UI genéricos (
src/components/ui/) — seguem padrão shadcn - Hooks (
src/hooks/) — permanecem flat com prefixo por domínio