Skip to content

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_id do 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árioMotivo
Dados vinculados a escola_idEscopo de tenant
Verificação de principal_roleAutorização
CREATE / UPDATE / DELETELog obrigatório
Cross-escola (admin)Bypass RLS controlado

Quando PODE usar Supabase Client direto

CenárioExemplo
Dados públicos sem escopobanners_login ativos
Upload de arquivossupabase.storage.from('bucket').upload()
Queries públicas read-onlySé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 Function

2. 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çãoFormato do BodyUso
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 livreCustomizado
invokeUploadBase64(fn, action, file, params)Base64 em JSONUpload (preferido — mantém cookies)
invokeUploadFormData(fn, formData)FormDataUpload (problemas com cookies cross-origin)

Interface de Resposta: EdgeResponse<T>

typescript
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

typescript
// ✅ 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

typescript
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ário

Regra: 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.

typescript
// ✅ 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çãoLogar?
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

typescript
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

typescript
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

PrefixoPapelExemplo
admin-*Administradoradmin-escolas, admin-usuarios
especialista-*Especialistaespecialista-olimpiadas, especialista-banners
escola-*Usuário escolaescola-dados, escola-dashboard
gestao-*Gestão interna da escolagestao-alunos, gestao-turmas
coordenador-*Coordenadorcoordenador-olimpiadas
diretor-*Diretordiretor-dashboard
portal-*Portal aluno/responsávelportal-escola

6. Padrão de Hook React (React Query — Obrigatório)

IMPORTANTE: Hooks que listam dados, fazem CRUD, ou servem dashboards DEVEM usar useQuery/useMutation do TanStack React Query. Veja critérios completos em REACT_QUERY_CACHE.md.

typescript
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 useQuery para leitura (não useState + useEffect)
  • [ ] Usa useMutation para escrita (não funções manuais)
  • [ ] Especifica tipo genérico <T> na chamada
  • [ ] Verifica result.success antes de usar result.data
  • [ ] enabled condicionado a isAuthenticated + papel
  • [ ] invalidateQueries no onSuccess de cada mutation
  • [ ] getUserFriendlyError(error) em TODOS os onError e catch
  • [ ] Toast via olpToast (nunca toast do Sonner direto)

7. Diagnóstico de Erros Comuns

ErroCausa ProvávelSolução
400 Bad Request + "Ação não reconhecida"Action inexistente no backendVerificar nome da action
401 Não autenticadoCookie não enviadoVerificar credentials: 'include' e CORS
data é undefinedNão verificou successAdicionar if (!result.success)
HTTP 406.single() retornou 0 rows (RLS bloqueou)Usar Edge Function com service_role
CORS error em preflightOrigin não permitidaAdicionar em ALLOWED_ORIGINS
Dados de outra escolaNão filtrou por escola_idAdicionar .eq("escola_id", user.escola_id)
Toast genéricoNão exibe message do backendUsar 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

typescript
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

TipoContextoParâmetros
otp_sistemaLogin admin{ otp }
otp_portalPortal aluno/responsável{ otp }
alerta_critico_cronCron jobs com falha{ jobName, tentativas, erro }
fatura_geradaFaturamento{ numeroFatura, valor, diaVencimento, linkPagamento }
lembrete_faturaFaturamento D-5{ numeroFatura, valor, dataVencimento, linkPagamento }
alerta_sessao_whatsappMonitoramento{ status, sessaoId }
solicitacao_planoSolicitação escola{ escola, plano, periodo, valor }

Regras

  • NUNCA construir strings de mensagem inline nas Edge Functions
  • SEMPRE importar gerarMensagemSMS do helper compartilhado
  • SEMPRE enviar via enviarMensagemComLog de _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
# .env (se usar Cloudflare Worker como gateway)
VITE_USE_WORKER=true
VITE_WORKER_URL=https://gateway.olp.digital

Se não configurado, usa Supabase diretamente.


11. Console Logging

MétodoUsoVisibilidade 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

  1. NUNCA usar console.log() em código que será commitado — use console.debug() para informações de diagnóstico
  2. console.warn() e console.error() são essenciais e NÃO devem ser removidos
  3. Em App.tsx, logs protegidos por import.meta.env.DEV são aceitáveis
  4. 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

FormatoLeituraEscrita
.xlsx✅ Via workbook.xlsx.load()✅ Via workbook.xlsx.writeBuffer()
.csv✅ Via parser manual centralizado❌ (não necessário)
.xlsNão suportado (formato legado)

Helpers Centralizados

FunçãoUso
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:

  1. O ArrayBuffer é decodificado como UTF-8
  2. O delimitador é detectado automaticamente (, vs ;) via detectarDelimitadorCSV()
  3. O parser manual (parseCSV) trata campos entre aspas, aspas escapadas (""), e CRLF/LF
  4. 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 fileName ao chamar lerExcelComoArray / lerExcelComoObjetos para 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:

typescript
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árioAção
.in() com array de até 100 IDsChamada normal (sem chunks)
.in() com array que pode exceder 100Usar queryInChunks
Queries de listagem com filtro por inscrições/alunosSempre usar chunks (volume imprevisível)

Onde já está implementado

  • gestao-resultados/index.ts — ações list, 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

  1. Frontend (useImportacaoResultados): Upload XLSX → detecção dinâmica de colunas → preview → confirmação
  2. Backend (gestao-resultados action start_import_session): Cria sessão em importacao_resultados_sessoesEdgeRuntime.waitUntil para processamento background
  3. Processamento: Batches de 50 resultados. Usa matrícula como chave. Auto-inscreve alunos sem inscrição (status confirmada).
  4. Pós-processamento: recomputeFaseForOlimpiada recalcula classificações e premiações uma vez ao final.
  5. Frontend polling: get_import_session_status a cada 2s. Detecção de stale (30 ciclos sem progresso).

Detecção Dinâmica de Colunas

typescript
// 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

  1. Upload: Aceita .xlsx e .csv via ExcelJS
  2. Mapeamento: Headers do arquivo → campos do sistema (com auto-detecção)
  3. Detecção de turma: Coluna de turma/série detectada automaticamente via patterns (turma, classe, sala, série)
  4. Agrupamento: Alunos agrupados por turma com matching automático contra turmas existentes
  5. Validação de duplicidades: Verifica CPF, matrícula e nome completo contra alunos existentes da escola
  6. Conflitos: Modal de resolução com opções: ignorar, sobrescrever, editar
  7. Processamento background: Batches de 50 via EdgeRuntime.waitUntil
  8. Resiliência: ID da sessão em localStorage (reconexão após F5), dados do wizard em sessionStorage

Componentes Frontend

ComponenteResponsabilidade
importacao-alunos/index.tsxWizard principal (upload → mapping → preview → resultado)
TurmaPreviewCard.tsxPreview de alunos agrupados por turma
ModalConflitos.tsxResolução de duplicidades
CardConflito.tsxCard individual de conflito
ModalCriarTurma.tsxCriar turma durante importação
ModalEditarAluno.tsxEditar dados de aluno antes de importar
turma-matching.tsLó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

text
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

  1. index.tsx é o único export público do diretório
  2. Hooks de dados (React Query) são chamados no index.tsx, não nos filhos
  3. Subcomponentes recebem dados e callbacks via props tipadas (sem any)
  4. Estados de UI locais (modais, formulários) ficam no subcomponente que os usa
  5. helpers.ts não importa React — apenas tipos e funções puras
  6. Nomes de arquivo usam prefixo do domínio para evitar colisões globais

Exemplos aprovados

DiretórioArquivosOrigem
src/components/agenda/7Split de dashboard-coordenador.tsx (1410 linhas)
src/components/coordenador/resultados/8Split de resultados do coordenador
src/components/mural-olimpico/15+Mural Olímpico completo
src/components/importacao-alunos/7Importaçã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