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)
Flash de skeleton ao trocar tabsif (isLoading) sem && !data com cache pré-populadoGuardar com isLoading && !data → Ref: ATOMIC_RENDERING.md
Re-render cascata em tabela memoizadauseEffect calculando campo derivado dispara handleChangeMover cálculo para handleSave ou useMemo
Warning "cannot be given refs" em SelectTooltip envolvendo SelectItem (Radix)Remover wrapper Radix, usar title attr nativo
Invalidação global desnecessáriaMutation invalida queryKeys.allInvalidar apenas queryKeys.subKey específico
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

Padrões visuais e de composição: ver FRONTEND_UI_STANDARDS.md — regras para Select (cap max-h-[200px]), Dialog, Tooltip, cabeçalhos com TutorialModal, tokens.

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