Skip to content

@audit — Checklist de Auditoria de Código

Invocar com a palavra-chave @audit — última atualização: 2026-04-26 · v3.1

Este documento valida conformidade pós-entrega. Para investigar bugs ("por que X não funciona"), use PROBLEM_SOLVING.md — não este.


Como usar este documento

Decida o escopo ANTES de seguir o checklist. Cada modo aponta apenas as seções relevantes — evita "fazer tudo de uma vez sem foco".

Modo de invocaçãoQuando usarSeções a executar
@audit (completo)Antes de release / fim de feature grandePartes A → E (todas)
@audit backendMexi em Edge Function / migration / RLSParte A §1–§5, §8 + Parte C
@audit frontendMexi em componente / hook / animação / UX visualParte B §6–§7, §10.7–§10.9 + → AUDIT_FRONTEND.md
@audit segurançaSuspeita de vulnerabilidade / IDOR / leak / rodar Security ScanParte C completa (§11 → §11.8) + → AUDIT_SECURITY.md
@audit perfTela lenta / loop / re-renders excessivos / mutação demora >500msParte B §8.5 (régua de latência percebida — OBRIGATÓRIA primeiro) + §9–§10.9
@audit docsMudei schema/edge function — verificar SSOTParte E §16
@audit feature <nome>Feature data-heavy com filtros silenciosos / mapeamento N:N / bug "dado some"Parte F (8 seções fixas)

⚠️ Diagnóstico de bug ≠ Auditoria. Se o sintoma é "X não funciona / aparece errado / trava", comece por PROBLEM_SOLVING.md. Este documento é para validar conformidade DEPOIS que o código está pronto, não para descobrir a causa raiz.

🎨 Auditoria de frontend visual (animações, hierarquia DOM/CSS, comportamento visual) tem SSOT dedicado: AUDIT_FRONTEND.md. Esta é uma extensão obrigatória do @audit frontend.

🛡️ Auditoria de segurança automatizada (Lovable Security Scan + triagem + plano priorizado) tem SSOT dedicado: AUDIT_SECURITY.md. Esta é uma extensão obrigatória do @audit segurança.


Metodologia de Execução

Ordem de auditoria (modo completo)

  1. Backend (Edge Functions, shared helpers) → onde erros são mais críticos
  2. Frontend (hooks, componentes, lib/) → qualidade, UX e AUDIT_FRONTEND.md para visual
  3. Documentação (docs/) → fidelidade ao código real

Escopo

  • Auditar o domínio em trabalho + domínios adjacentes impactados
  • Para auditoria completa: varrer todos os 16 domínios
  • Cada domínio tem "Como buscar" com patterns exatos para search_files / grep

Severidade

NívelSignificadoAção
🔴 CríticoSegurança, perda de dados, falha silenciosaBloqueia entrega
🟡 AtençãoDesvio de padrão, debt técnicoCorrigir na sprint
🟢 RecomendaçãoMelhoria opcionalBacklog

PARTE A — QUALIDADE DE CÓDIGO

1. Logging

O que verificar

  • [ ] Todo Create/Update/Delete chama registrarLog() com req
  • [ ] Reads NÃO logam (nunca registrarLog em SELECT)
  • [ ] Ação segue formato <modulo>.<operacao> lowercase
  • [ ] Diff usa gerarAlteracoes(antes, depois) para updates
  • [ ] Ação cadastrada em src/constants/log-actions.ts
  • [ ] req passado como parâmetro (geolocalização via Cloudflare headers)

Como buscar

# Verificar que CUD tem logging
search_files: "\.insert\(|\.update\(|\.delete\(" em supabase/functions/
→ Para cada match, verificar se há registrarLog() na mesma action

# Verificar reads sem logging indevido
search_files: "registrarLog" em supabase/functions/
→ Confirmar que nenhum está em ação de leitura (list, get, buscar)

# Ações sem cadastro
search_files: "registrarLog.*acao:" em supabase/functions/
→ Extrair valores de ação e cruzar com src/constants/log-actions.ts

Erros recorrentes

  • req faltando no registrarLog() → geolocalização não funciona
  • Ação não cadastrada em log-actions.ts → filtro de logs quebrado
  • gerarAlteracoes não usado em updates → diff vazio no log

2. Incidentes

O que verificar

  • [ ] Falhas técnicas (não de input do usuário) têm ação .erro/.falha_*
  • [ ] Ação de erro está na lista monitorada (CRON_JOBS_REGISTRY.errorActions ou acoesExtras)
  • [ ] Fluxo normal do usuário NÃO está na lista de incidentes
  • [ ] detalhes do log tem campo de erro acessível por extrairErroIncidente

Como buscar

# Ações de erro não monitoradas
search_files: "\.erro|\.falha_|\.critico" em supabase/functions/
→ Cruzar com lista em admin-incidentes.tsx (acoesExtras + CRON_JOBS_REGISTRY)

# Fluxo normal classificado como incidente (falso positivo)
→ Verificar lista monitorada: ações como "escola.create" NÃO devem estar lá

Ref: docs/operations/INCIDENT_POLICY.md


3. RBAC e Permissões

O que verificar

  • [ ] requireRole() antes de operações sensíveis com papel correto
  • [ ] Papéis sem tela (pedagogico, professor, marketing) bloqueados em 4 camadas
  • [ ] useMyPermissions filtra sidebar corretamente
  • [ ] Novas permissões adicionadas ao enum permissao_area se necessário
  • [ ] Ownership check: usuário só acessa dados da sua escola (escola_id do JWT)
  • [ ] 🔴 Permissões escopadas por papel_id: toda leitura/escrita em usuarios_escola_permissoes e usuarios_escola_sub_permissoes DEVE filtrar por papel_id — nunca só usuario_id + escola_id
  • [ ] 🔴 Seed de permissões por vínculo: ao criar vínculo, seedPermissionsForRole recebe papel_id explícito
  • [ ] 🔴 Remoção de permissões isolada: ao remover vínculo, removePermissionsForRole deleta APENAS permissões do papel_id removido — nunca de outro papel na mesma escola
  • [ ] Permissões mínimas obrigatórias: coordenador→agenda, diretor→painel_geral
  • [ ] Lazy seed de sub-permissões: se usuarios_escola_sub_permissoes está vazio para o par (usuario_id, escola_id, papel_id), backend ativa todas por padrão

Como buscar

# Edge functions sem requireRole
search_files: "case '" em supabase/functions/ (actions de switch)
→ Para cada action CUD, verificar se há requireRole() antes

# Papéis bloqueados
search_files: "pedagogico|professor|marketing" em supabase/functions/
→ Devem aparecer apenas em bloqueios, nunca como papéis permitidos

# Permissões sem escopo papel_id (CRÍTICO desde Abr/2026)
search_files: "usuarios_escola_permissoes" em supabase/functions/
→ Todo SELECT/INSERT/DELETE DEVE ter .eq('papel_id', ...) 
→ Ausência de papel_id = colisão entre papéis na mesma escola

# Seed/remove sem papel_id
search_files: "seedPermissionsForRole|removePermissionsForRole" em supabase/functions/
→ Ambos DEVEM receber papel_id como parâmetro

Erros recorrentes

  • Permissões escopadas só por usuario_id + escola_id → diretor e coordenador na mesma escola colidem (Caso 7 de PROBLEM_SOLVING.md)
  • get_permissoes_usuario sem papel_id → retorna permissões do primeiro vínculo encontrado, não do badge selecionado
  • update_permissoes_usuario sem papel_id → salva contra papel errado, UI silenciosa
  • Frontend passa papel_id: '' no mapeamento de badges → backend não encontra vínculo, falha silenciosa

4. RLS — Silent Failure

Pré-entrega: Antes de submeter mudanças de banco/RLS, executar o protocolo obrigatório em MIGRATION_SAFETY_PROTOCOL.md. Este checklist (@audit) é pós-entrega.

O que verificar

  • [ ] Tabelas novas têm policies de SELECT, INSERT, UPDATE, DELETE
  • [ ] 🔴 Silent failure tratada: após .update() e .delete(), verificar data.length === 0 ou usar .select() para confirmar
  • [ ] Sem uso de service_role onde createSupabaseClient(req) basta
  • [ ] createSupabaseSystem() usado apenas para logging, auth, crons

Como buscar

# Updates/deletes sem verificação de resultado
search_files: "\.update\(|\.delete\(" em supabase/functions/
→ Para cada match, verificar se há .select() encadeado OU verificação de count/data

# Uso indevido de createSupabaseSystem
search_files: "createSupabaseSystem" em supabase/functions/
→ Deve aparecer APENAS em logging-helper.ts, auth-helpers.ts, cron jobs
→ Qualquer outro uso é 🔴 CRÍTICO

# Inline createClient (PROIBIDO)
search_files: "createClient\(" em supabase/functions/
→ Deve aparecer APENAS em supabase-client.ts (o shared helper)
→ Qualquer outro arquivo é 🔴 CRÍTICO

Erros recorrentes

  • .update({}).eq('id', x) sem .select() → RLS bloqueia silenciosamente, retorna { data: null, error: null }
  • createClient inline em edge function → bypassa padrão de segurança
  • createSupabaseSystem() usado para operações de usuário → bypassa RLS

5. Edge Function — Estrutura

O que verificar

  • [ ] verify_jwt = false no config.toml (auth é via cookie, não JWT do Supabase)
  • [ ] handleCorsPrelight(req) no início
  • [ ] getCorsHeaders(req) em TODAS as respostas (incluindo erros e catch)
  • [ ] Ações públicas ANTES do auth check
  • [ ] Ações autenticadas DEPOIS do extractAuthenticatedUser(req)
  • [ ] Formato de resposta: { success, data } ou { success, message }
  • [ ] Frontend usa invokeAction/invokeEdge (nunca fetch direto)
  • [ ] 🔴 Contrato invokeAction ↔ Zod: handler de action dispatch DEVE extrair const rawParams = body.params ?? body ANTES de Schema.safeParse(...). invokeAction(fn, action, params) envia body { action, params: {...} } — parsear o body raiz quebra com "<campo>: Required" mesmo com payload válido. Bug histórico: fatura_id: Required 2026-06-19 (ver PROBLEM_SOLVING Caso 21, ADR-003).
  • [ ] 🔴 Status HTTP para erro de provedor externo (MP, Wasender, Twilio): 4xx do provedor = 422 + code semântico + mensagem amigável. 5xx/timeout/network = 502 (upstream caiu de fato). NUNCA 502 indiscriminado para qualquer falha do provedor — o wrapper invokeEdge trata 502 como transitório e dispara retry (3 chamadas ao provedor por clique) + mensagem genérica que esconde a causa real. Helper SSOT: _shared/payment-gateway.ts → classifyMpError(). Bug histórico: "expiration date can not be greater than 29 days" virou "Servidor temporariamente indisponível (502)" — 2026-06-19 (ver PROBLEM_SOLVING Caso 22, mem://architecture/external-provider-error-status-mapping).
  • [ ] 🔴 Pré-validação de limites operacionais conhecidos do provedor ANTES da chamada HTTP. Exemplo: boleto MP só aceita vencimento até 29 dias (MP_BOLETO_MAX_DAYS_AHEAD). Pré-validar evita 3 round-trips por clique e dá mensagem clara. Catalogar novos limites em docs/features/billing/boleto-manual.md §"Limites operacionais".
  • [ ] 🔴 Testes em __tests__/: nenhum *.test.ts co-localizado na raiz da pasta da function (causa OOM/exit 135 no deploy). Rodar: bash scripts/check-edge-function-test-location.sh — DEVE retornar 0 violações.

Como buscar

# Respostas sem CORS headers (especialmente em catch)
search_files: "new Response\(" em supabase/functions/
→ Cada Response DEVE ter getCorsHeaders(req) nos headers

# Frontend com fetch direto para edge functions
search_files: "fetch\(.*supabase.*functions" em src/
→ Deve ser ZERO (usar invokeAction/invokeEdge)

# invokeAction não usado
search_files: "supabase\.functions\.invoke" em src/
→ Deve ser ZERO (usar invokeAction/invokeEdge de edge-function.ts)

# 🔴 Zod no body raiz (suspeita de contrato quebrado com invokeAction)
search_files: "safeParse\(body\)|\.parse\(body\)" em supabase/functions/
→ Em handler com switch(action), DEVE ser safeParse(body.params ?? body)
→ Cada match é candidato a bug "Required" silencioso até validar com teste de parser puro

Erros recorrentes

  • catch block retorna Response sem getCorsHeaders(req) → CORS error no browser
  • Frontend usa fetch() direto → não passa cookies, auth falha
  • extractAuthenticatedUser chamado antes de actions públicas → público não funciona
  • 🔴 Schema.safeParse(body) em handler invocado via invokeAction → rejeita 100% dos payloads válidos com "<campo>: Required". Fix: safeParse(body.params ?? body).
  • 🔴 return json({...}, 502) para QUALQUER falha de provedor externo (MP/Wasender/etc.) → wrapper frontend faz retry desperdiçando cota e mostra mensagem genérica. Fix: usar classifyMpError() (ou equivalente por provedor) e mapear para 422 quando erro for de domínio. Pattern de busca: }, 502, em supabase/functions/.

6. React Query — Migração

O que verificar

  • [ ] useQuery com staleTime definido (nunca default 0)
  • [ ] queryKey inclui todos os filtros/parâmetros relevantes
  • [ ] Mutations usam invalidateQueries no onSuccess (ou onSettled no padrão Optimistic)
  • [ ] 🔴 Sem anti-padrão useState([]) + useEffect para fetch
  • [ ] enabled usado quando query depende de parâmetro que pode ser undefined
  • [ ] 🔴 enabled NÃO depende de dado que só vem do resultado da própria query (deadlock circular)
  • [ ] Mutations interativas (toggle/drag/delete/inline-edit): aplicam padrão Optimistic Update OU possuem // TODO(optimistic-update) rastreável OU // NOTE(pessimista-intencional) justificado — ver OPTIMISTIC_UPDATES.md

Como buscar

# Anti-padrão useState + useEffect para fetch (LEGADO)
search_files: "useState\(\[\]\)" em src/hooks/
→ Se acompanhado de useEffect com invokeAction/invokeEdge → hook legado, migrar

# staleTime faltando
search_files: "useQuery\(" em src/hooks/
→ Cada useQuery DEVE ter staleTime explícito

# queryKey incompleta
search_files: "queryKey:" em src/hooks/
→ Verificar que inclui todos os parâmetros de filtro (escolaId, faseId, etc.)

# Mutation sem invalidação
search_files: "useMutation\(" em src/hooks/
→ Cada mutation DEVE ter onSuccess com invalidateQueries

# Deadlock circular em enabled
search_files: "enabled:" em src/hooks/
→ Se enabled depende de estado que só é setado no onSuccess/data da MESMA query → 🔴 deadlock

Tabela de staleTime por categoria

CategoriastaleTimeExemplos
Dashboards5 minadmin-dashboard-metrics
Listas CRUD5 minalunos, turmas, usuários
Configurações10 minescola-dados, mural-config
Dados quase estáticos30 minolimpiadas, escolas-admin, papéis
Feature flags10 minfeature-flags

Hooks legados restantes (rastrear migração)

Consultar docs/development/REACT_QUERY_CACHE.md §8 para lista atualizada.


7. Error Handling — Toasts (Frontend)

O que verificar

  • [ ] 🔴 Sem import { toast } from 'sonner' — usar olpToast de @/components/ui/use-olp-toast
  • [ ] 🔴 Sem error.message cru em toasts — usar getUserFriendlyError(error)
  • [ ] 🔴 Sem alert() — usar olpToast
  • [ ] Mensagens de erro genéricas para o usuário (sem stack traces, sem constraint names)
  • [ ] Erros técnicos logados via console.error para debug

Como buscar

# Import direto do Sonner (PROIBIDO)
search_files: "from 'sonner'" em src/components/
→ Deve ser ZERO (exceto sonner.tsx que é o wrapper)

# error.message cru em toast
search_files: "description: (err|error)\.message" em src/
→ Deve ser ZERO — usar getUserFriendlyError(error)

# alert() no código
search_files: "alert\(" em src/components/
→ Deve ser ZERO

# Toast sem getUserFriendlyError em catch blocks
search_files: "catch.*\{" em src/components/ e src/hooks/
→ Verificar que erros passam por getUserFriendlyError antes de exibir

Erros recorrentes

  • toast.error('Erro', { description: error.message }) → vaza constraint name do Postgres
  • import { toast } from 'sonner' → perde padronização de estilo/ícone
  • Enum leak: backend retorna "duplicate key" e frontend exibe literalmente
  • Try/catch sem toast → erro silencioso, usuário não sabe o que aconteceu

7.5. UI de Listas com Mutations (loading/alinhamento/payload)

Lições aprendidas na entrega de encargos-atraso (2026-06-19). Toda lista (<Table>/<ul>) que dispara mutations por linha precisa:

O que verificar

  • [ ] 🔴 Loading por linha, não global. Usar pendingId: string | null no componente; isPending global do hook só pode ser usado quando só uma linha pode disparar a mutation por vez.
    • Bug típico: clicar em "Gerar boleto" da linha 5 deixa todos os botões em loading.
  • [ ] 🔴 Coluna Ações alinhada à direita mesmo em linhas com poucos botões: <TableHead className="text-right"> + <div className="flex items-center justify-end gap-2">. Sem isso, linhas pagas/canceladas (1 ícone) ficam coladas à esquerda enquanto pendentes (N ícones) empurram para a direita → desalinhamento visual.
  • [ ] 🔴 Botões de ação repetidos = icon-only. Quando o mesmo botão aparece em N linhas, ele deve ser size="icon" com title=/aria-label=, nunca <Receipt /> Gerar boleto (texto consome largura, força quebra de coluna).
  • [ ] 🔴 Guarda de payload no frontend antes da mutation. Handler deve validar IDs (if (!fatura?.id) return olpToast.error(...)) e o mutationFn do hook deve re-checar (assertFaturaId). Zod no backend é segunda camada — usuário não pode ver "Payload inválido (fatura_id: Required)".
  • [ ] disabled coerente com pré-condições do payload (disabled={pendingId === row.id || !row.id}).

Como buscar

# Loading global em lista → suspeito
search_files: "disabled=\{(isPending|isGenerating|isLoading|.*Loading)\}.*\.map" em src/components/
→ Se a mesma flag é usada em .map() row → trocar por pendingId === row.id

# Coluna Ações sem text-right
search_files: "TableHead>Ações" em src/components/
→ Deve ser <TableHead className="text-right">Ações</TableHead>

# Mutation sem guard de ID
search_files: "mutationFn.*async.*invokeAction" em src/hooks/
→ Verificar que IDs UUID são validados antes do invoke

Erros recorrentes

  • Hook expõe só isGenerating: boolean → componente não tem como rastrear loading por linha. Solução: aceitar que loading é responsabilidade do componente (state local pendingId), o hook só fornece a mutation.
  • Botão "Gerar boleto" full-label em coluna Ações compartilhada com badges → quebra layout em viewports médios.
  • Race entre carregarFaturas (lista) e mutation → click chega com fatura.id === undefined; backend retorna 400 cru.

8. Error Handling — Backend

O que verificar

  • [ ] Todo insert/update/delete verifica { error } no retorno
  • [ ] Erros de operações críticas (que barram o fluxo) retornam HTTP 400/500
  • [ ] Erros de operações complementares (logging, cache) logam mas NÃO barram
  • [ ] detalhes do log de erro tem informação suficiente para debug

Como buscar

# Insert/update/delete sem verificação de erro
search_files: "\.insert\(|\.update\(|\.delete\(" em supabase/functions/
→ Cada operação DEVE ter: const { data, error } = await ... seguido de if (error)

# Erros complementares que barram fluxo (falso bloqueio)
→ Verificar se falha de registrarLog ou cache geo bloqueia a response principal
→ Deve ser APENAS console.error, não throw

Template de verificação

typescript
// ✅ CORRETO — Operação crítica
const { data, error } = await supabase.from('tabela').insert(dados).select();
if (error) {
  await registrarLog(supabaseSystem, { ... acao: 'modulo.erro', detalhes: { error: error.message } }, req);
  return new Response(JSON.stringify({ success: false, message: 'Erro ao criar registro' }), 
    { status: 400, headers: getCorsHeaders(req) });
}

// ✅ CORRETO — Operação complementar (não barra)
try {
  await registrarLog(supabaseSystem, { ... }, req);
} catch (logErr) {
  console.error('Falha ao registrar log:', logErr);
  // NÃO retorna erro — operação principal já foi concluída
}

PARTE B — PERFORMANCE

⚠️ Régua obrigatória ANTES das demais seções de Parte B. A régua de latência percebida (§8.5) classifica findings pelo gatilho de UI, não pelo risco de timeout. Aplicar a TODA Edge Function tocada na entrega — inclusive as "que ainda não quebraram".

8.5. Régua de Latência Percebida (por gatilho de usuário)

Por que existe. A auditoria de 2026-06-04 (docs/audits/perf/2026-06-04/03_RECOMPUTE_BATCH_HOTPATH.md) descreveu corretamente o anti-padrão de recompute síncrono em set_situacao_aluno, mas classificou como risco prospectivo de timeout (8k inscrições) em vez de dor presente de UX (272 inscrições = 10s no Response). Resultado: o bug ficou no roadmap como "Onda B" por meses, degradando a experiência do coordenador a cada edição manual. Esta régua impede a repetição: severidade é definida pelo gatilho de UI, não pelo limite de 150s do Edge Runtime. Ver Caso 20 em PROBLEM_SOLVING.md e ADR-016.

Definição de gatilho

GatilhoDefiniçãoExemplos
Singular1 click do usuário → 1 linha de dado editadaToggle de checkbox, edição de 1 célula em modal, save de 1 campo
Lote pequeno1 click → ≤50 linhas editadasSave de faixas de premiação, set de nota de corte para 1 nível
Lote grande1 click → >50 linhas, OU disparo explícito de "importar/processar"Importação de planilha, recompute em massa, geração de relatório

Orçamento de latência percebida (do click ao toast)

Gatilho🟢 Verde🟡 Amarelo (finding)🔴 Vermelho (finding crítico)
Singular≤500ms500ms–2s>2s
Lote pequeno≤2s2s–5s>5s
Lote granderesposta imediata com task_id + pollingresposta síncrona ≤30sresposta síncrona >30s OU sem desacoplamento

Regra de ouro: trabalho derivado (recompute global, snapshot, log de auditoria, notificação) NUNCA conta no orçamento da mutação singular. Se conta, é finding automático — não importa se "ainda não dá timeout".

O que verificar

  • [ ] 🔴 Toda action que serve gatilho singular responde em <2s no caminho crítico. Trabalho derivado vai para EdgeRuntime.waitUntil(...) — ver ADR-016 e mem://architecture/performance/post-mutation-recompute-standard.
  • [ ] 🔴 Recompute global (que itera sobre todos os irmãos da fase/nível/turma) nunca roda dentro do Response de uma mutação singular.
  • [ ] 🔴 Loops de N UPDATEs individuais (Promise.all(rows.map(r => update(r.id)))) em handler de mutação singular = finding crítico, mesmo se N atual ≤100. Substituir por diff em memória (escreve apenas linhas que mudaram) ou upsert em lote.
  • [ ] 🔴 registrarLog/snapshot/enviarAlertaPush no Response de mutação singular → mover para EdgeRuntime.waitUntil.
  • [ ] 🟡 Handler que serve múltiplos gatilhos (ex: mesma action chamada por edição de 1 célula E por save em lote) precisa ramificar o caminho assíncrono pelo tamanho do input.
  • [ ] 🟡 Frontend: mutação singular sem invalidateAll atrasado (~2.5s) quando backend usa recompute em background — UI mostra estado intermediário sem convergir.

Como auditar (passo a passo)

  1. Mapear gatilhos → actions. Para cada Edge Function tocada na entrega, listar quais elementos de UI a invocam (button onClick, mutation, save modal). Cada gatilho recebe um rótulo: Singular / Lote pequeno / Lote grande.
  2. Medir o presente, não projetar o futuro. Abrir DevTools → Network → executar o gatilho 1× com dataset típico de produção (não staging vazio). Anotar duração da request. Comparar com o orçamento da tabela acima.
  3. Inspecionar o handler. Para cada action de gatilho singular, ler do início ao return new Response. Marcar todo trabalho que NÃO afeta a linha editada (recompute global, snapshot, log, notificação). Esse trabalho é candidato a EdgeRuntime.waitUntil.
  4. Contar UPDATEs por mutação. Se 1 click do usuário gera N UPDATEs no banco (N = nº de irmãos), é finding crítico — substituir por diff.
  5. Cruzar com PROBLEM_SOLVING.md Caso 20. Se o padrão bate, citar o caso no relatório de auditoria.

Como buscar

# Recompute/snapshot/log no caminho do Response (sem waitUntil)
search_files: "recompute|snapshot|registrarLog" em supabase/functions/
→ Para cada match, verificar se está dentro de EdgeRuntime.waitUntil(...)
→ Se está antes do `return new Response` em handler de mutação singular → 🔴 CRÍTICO

# Loop de UPDATEs individuais em handler de mutação
search_files: "Promise\.all\(.*\.map\(.*update\(" em supabase/functions/
→ Se o array iterado vem de "todos os irmãos da fase/nível" e o handler atende 1 click → 🔴 CRÍTICO

# Frontend: mutação singular sem re-invalidate atrasado
search_files: "useMutation" em src/hooks/
→ Se a edge function correspondente usa background task → onSuccess deve ter setTimeout(invalidateAll, ~2500)

Erros recorrentes

  • "Está OK porque ainda não dá timeout" → ignora UX presente; vira finding amarelo automático até prova de medição <2s.
  • Recompute global no Response porque "é idempotente e simples" → idempotência permite mover para background, não justifica ficar no caminho crítico.
  • await registrarLog(...) antes do return Response → adiciona 150–400ms por log; mover para EdgeRuntime.waitUntil.
  • Frontend faz invalidateAll único após mutação singular cuja parte derivada é assíncrona → ranks de outros alunos ficam congelados até próxima navegação.

→ Ref: ADR-016, mem://architecture/performance/post-mutation-recompute-standard, mem://architecture/edge-function-background-task-standard, PROBLEM_SOLVING.md Caso 20.


9. React Query Cache

O que verificar

  • [ ] staleTime definido e compatível com a tabela da seção 6
  • [ ] refetchOnWindowFocus não está true sem motivo (React Query v5 default é true)
  • [ ] Queries com enabled: false quando parâmetros são undefined/null
  • [ ] Sem queries duplicadas (mesmo dado buscado por hooks diferentes)
  • [ ] placeholderData ou initialData usado quando faz sentido (UX de loading)

Como buscar

# Queries sem staleTime
search_files: "useQuery\(\{" em src/hooks/
→ Verificar presença de staleTime em cada uma

# Queries que recarregam na troca de aba sem necessidade
search_files: "refetchOnWindowFocus" em src/hooks/
→ Verificar se faz sentido para o tipo de dado

10. Query Chunking

O que verificar

  • [ ] .in() com arrays que podem ter >100 itens usa chunking
  • [ ] Queries com SELECT * desnecessário (buscar apenas campos usados)
  • [ ] Paginação implementada em listagens grandes (>100 itens esperados)

Como buscar

# .in() sem chunking
search_files: "\.in\(" em supabase/functions/
→ Verificar se o array fonte pode crescer além de 100

# SELECT * desnecessário
search_files: "\.select\(\)" em supabase/functions/  (select sem campos)
→ Preferir .select('campo1, campo2') para reduzir payload

# Listagens sem paginação
search_files: "\.select\(" em supabase/functions/ com action "list" ou "listar"
→ Verificar se há .range() ou .limit()

10.5 Batching de Requisições (batch_init)

O que verificar

  • [ ] Telas com 3+ requests paralelos no mount avaliam consolidação em batch_init
  • [ ] Backend usa Promise.all para paralelizar queries internas (não sequenciais)
  • [ ] Resposta única com todos os dados necessários: { olimpiadas, stats, config }
  • [ ] Overhead repetido de auth/feature-flag/CORS eliminado

Como buscar

# Componentes com 3+ useQuery para o mesmo backend
search_files: "useQuery\(" em src/hooks/
→ Se um hook tem 3+ useQuery para a mesma Edge Function → candidato a batch_init

# Edge Functions sem batch_init em telas pesadas
→ Verificar telas: Resultados, Mural, Dashboard, Mensagens
→ Se fazem 3+ requests separados → implementar batch_init

Overhead por request

Auth check + feature flag check + CORS = ~100-300ms de overhead cada. Com 4 requests paralelos = ~400-1200ms desperdiçados. batch_init reduz para ~100-300ms total.

Exemplos implementados

TelaActionConsolida
Resultadosbatch_init_resultadoslistagem + stats + stats_agregadas
Muralbatch_initlistagem + olimpíadas + liberações + config

10.6 N+1 Query Detection

O que verificar

  • [ ] 🟡 Sem .from('tabela').select() dentro de for/map/forEach em Edge Functions
  • [ ] Substituído por .in('campo', arrayDeIds) com queryInChunks se >100 IDs
  • [ ] Promise.all usado para queries independentes (não await sequencial em loop)

Como buscar

# N+1 em loop
search_files: "for.*await.*\.from\(" em supabase/functions/
→ Cada match é provável N+1 — substituir por .in() consolidado

# await sequencial em loop (variante)
search_files: "for \(const.*of.*\).*\{" em supabase/functions/
→ Se contém await dentro do loop → candidato a Promise.all ou .in()

Exemplo de correção

typescript
// ❌ N+1 — O(n) queries
for (const olp of olimpiadas) {
  const { data } = await supabase.from('fases').select().eq('olimpiada_id', olp.id);
}

// ✅ Consolidado — O(1) query
const ids = olimpiadas.map(o => o.id);
const { data } = await supabase.from('fases').select().in('olimpiada_id', ids);

10.7 Hook Stability (useCallback)

O que verificar

  • [ ] 🔴 Funções retornadas por hooks custom que podem ser usadas como deps de useEffect DEVEM ter useCallback
  • [ ] Dependências do useCallback são estáveis (queryClient, mutation, refetch — não dados reativos)
  • [ ] useEffect de mount redundante removido quando React Query já auto-carrega
  • [ ] Sem useEffect com funções instáveis como dependências

Como buscar

# Funções retornadas por hook sem useCallback
search_files: "return \{" em src/hooks/
→ Para cada hook, verificar se funções no retorno têm useCallback
→ Se não → 🔴 CRÍTICO (causa loop infinito)

# useEffect com funções de hook como deps
search_files: "useEffect\(" em src/components/
→ Se deps incluem funções vindas de hooks → verificar se são estáveis (useCallback)
→ Se não → loop infinito garantido

# useEffect redundante (React Query já auto-carrega)
→ Se useEffect chama função que é essencialmente um refetch do useQuery → remover

Padrão de Detecção de Loop Infinito

Network tab mostrando avalanche de requests idênticos com timestamps crescentes em <1 segundo = loop infinito de re-renders. Causa mais comum: função de hook sem useCallback usada como dep de useEffect.


10.8 Renderização Atômica e Performance de Componentes

O que verificar

  • [ ] 🔴 Anti-Flash: if (isLoading) sem && !data em componentes com cache pré-populado (batch_init, setQueryData)
  • [ ] 🔴 useEffect derivado: useEffect que chama setState/handleChange para campos derivados (ex: termino = inicio + duracao)
  • [ ] 🟡 Radix wrapper conflict: Primitivas Radix (SelectItem, DropdownMenuItem) envolvidas em outras primitivas Radix (Tooltip, Popover)
  • [ ] 🟡 Memoização quebrada: React.memo em componente cujos props mudam desnecessariamente (callbacks instáveis, objetos recriados)
  • [ ] 🟡 Invalidação granular: Mutations que invalidam queryKeys.all quando só precisam invalidar queryKeys.subKey

Como buscar

# Anti-flash: guard sem && !data em componente com cache pré-populado
search_files: "if (isLoading)" em src/components/
→ Se acompanhado de useQuery com cache pré-populado (batch_init) → deve ter && !data

# useEffect que calcula campo derivado (anti-padrão)
search_files: "useEffect" em src/components/
→ Se dentro do useEffect há chamada a handleChange/setState para campo derivado → 🔴 mover para save/useMemo

# Radix wrapper conflict
search_files: "TooltipTrigger" em src/components/
→ Se envolve SelectItem, DropdownMenuItem ou outra primitiva Radix → 🟡 remover wrapper

# Invalidação não-granular
search_files: "invalidateQueries" em src/hooks/
→ Se invalida queryKeys.all em mutation que só afeta um sub-cache → 🟡 refinar para queryKeys.subKey

Erros recorrentes

  • if (isLoading) return <Skeleton /> com batch_init → flash de 1 frame ao trocar tab (Caso 8 de PROBLEM_SOLVING.md)
  • useEffect que calcula horario_termino chamando handleChange → invalida React.memo de todas as rows (Caso 9)
  • TooltipTrigger envolvendo SelectItem → warning "Function components cannot be given refs" (Caso 10)
  • Mutation de Ações-Chave invalida cache de Aplicações → refetch desnecessário em tab adjacente

→ Ref: docs/development/ATOMIC_RENDERING.md, docs/development/PROBLEM_SOLVING.md Casos 8-10


10.9 Animações, Hierarquia DOM/CSS e Comportamento Visual

🎨 SSOT detalhado: AUDIT_FRONTEND.md — sempre executar quando mexer em UI visual, animação, transição, z-index, overflow ou media query.

O que verificar (resumo — detalhamento em AUDIT_FRONTEND.md)

  • [ ] 🔴 Animações cíclicas usam requestAnimationFrame (não setTimeout/setInterval)
  • [ ] 🔴 prefers-reduced-motion tem política documentada no JSDoc do componente (off | reduced | animated-forced)
  • [ ] 🔴 Estado interno crítico exposto via data-* (ex: data-phase, data-state) para diagnóstico via DevTools sem ler código-fonte
  • [ ] 🔴 Sem CSS transition combinado com transform calculado por rAF (race condition garantida)
  • [ ] 🔴 Early-return de prefers-reduced-motion NÃO renderiza JSX com transform estático intermediário (parece bug travado)
  • [ ] 🟡 isolate no container quando há z-index interno + comentário com hierarquia de planos
  • [ ] 🟡 Cleanup obrigatório no unmount: cancelAnimationFrame, clearTimeout, listener removal

Como buscar

# Animações com setTimeout/setInterval (preferir rAF para ciclos)
search_files: "setInterval\|setTimeout.*animation" em src/components/

# prefers-reduced-motion sem política documentada
search_files: "prefers-reduced-motion\|reducedMotion\|reduced-motion" em src/
→ cada match deve ter comentário explicando política (off/reduced/forced)

# Estado de animação não exposto via data-*
search_files: "useState.*phase\|useState.*animation" em src/components/
→ estado interno crítico DEVE ter data-* correspondente no JSX

# z-index sem isolate no pai
search_files: "z-\[?\d" em src/components/
→ pai DEVE ter `isolate` ou `position: relative` documentado

Erros recorrentes

  • Reescrever animação antes de inspecionar DOM no DevTools → 3 reescritas inúteis (ver Caso 12)
  • prefers-reduced-motion retorna JSX com translateX(75%) estático → aparenta bug visual
  • Estado de animação só no React state (sem data-phase) → diagnóstico exige ler código-fonte
  • CSS transition: transform 300ms em elemento cuja posição é calculada por rAF → flicker

→ Ref: AUDIT_FRONTEND.md §3 (Animações), PROBLEM_SOLVING.md Caso 12 (saga do shimmer URGENTE)


PARTE C — SEGURANÇA

11. Segurança Geral

O que verificar

  • [ ] 🔴 Input validation no backend (Zero Trust — não confiar no frontend)
  • [ ] 🔴 Sem SQL raw / queries inline (usar Supabase client)
  • [ ] 🔴 Sem dados sensíveis expostos no frontend (CPF mascarado, etc.)
  • [ ] 🔴 Sem service_role key no frontend
  • [ ] Rate-limits em endpoints públicos sensíveis (OTP, cadastro, lookup)
  • [ ] Ownership check: escola_id do JWT validado contra recurso acessado
  • [ ] Tokens/OTPs com expiração verificada

Como buscar

# service_role no frontend (CRÍTICO)
search_files: "SERVICE_ROLE" em src/
→ Deve ser ZERO

# SQL inline
search_files: "\.rpc\(|sql`|query\(" em supabase/functions/
→ Avaliar se há SQL injection risk

# Dados sensíveis sem máscara
search_files: "cpf|telefone|email" em src/components/
→ Verificar se há mascaramento visual (ex: ***.***.***-XX)

# Endpoints públicos sem rate limit
→ Verificar send-otp, verify-otp, cadastro-escola-publica, lookup

11.5. IDOR e Escalação de Privilégios

O que verificar

  • [ ] 🔴 Mutações (UPDATE/DELETE) filtram por escola_id do JWT — não apenas por id do body
  • [ ] 🔴 Deletes de entidades relacionadas são escopados à escola (ex: ao remover usuário, só remove papéis da escola do JWT)
  • [ ] 🔴 Leitura de dados sensíveis valida ownership antes de retornar (ex: inscricao.escola_id === user.escola_id)
  • [ ] 🔴 Operações admin-only não são acessíveis por papéis de escola (verificar requireRole com papéis corretos)
  • [ ] 🔴 Updates/deletes validam resultado com .select().maybeSingle() — RLS pode bloquear silenciosamente
  • [ ] Parâmetros de busca (searchTerm) são sanitizados antes de interpolar em .or() / .ilike() PostgREST

Como buscar

# Mutações sem escola_id guard
search_files: "\.update\(|\.delete\(" em supabase/functions/
→ Verificar se há .eq("escola_id", user.escola_id) ou ownership check equivalente

# searchTerm sem sanitização (Filter Injection)
search_files: "\.or\(`|\.ilike\." em supabase/functions/
→ Verificar se variável interpolada passou por .replace(/[%_(),.*!]/g, '')

# Deletes cross-escola
search_files: "\.delete\(\)" em supabase/functions/
→ Verificar se deletes de papéis/permissões filtram por escola_id

# RLS silent fail (update/delete sem validação)
search_files: "\.update\(.*\)\.eq\(" em supabase/functions/
→ Verificar se resultado é validado (não é apenas { error } mas também data check)

Erros recorrentes

  • Frontend envia id no body → backend usa apenas .eq("id", id) sem verificar escola_id → IDOR permite acesso cross-escola
  • .delete().eq("papel_id", id) sem .eq("escola_id") → remove papéis de TODAS as escolas (escalação)
  • .update({}).eq("id", id) retorna { error: null } mesmo quando RLS bloqueou → falha silenciosa
  • Permissões deletadas sem filtro por papel_id → ao salvar permissões de um papel, apaga permissões de outro papel na mesma escola

11.6. Falha Silenciosa em Operações de UI (Silent Save)

O que verificar

  • [ ] 🔴 Toda operação de save exibe toast de sucesso OU erro — sem caminho silencioso
  • [ ] 🔴 Wrappers de invokeAction em componentes admin verificam response.success e mostram olpToast
  • [ ] 🔴 Catch blocks NÃO engolem erros — erros devem propagar para toast ou re-throw
  • [ ] Pós-save rehydration: após save com sucesso, o estado da UI é recarregado (invalidateQueries, refetch, ou reset de baseline)
  • [ ] Baseline de permissões: botão "Salvar" só aparece quando há diff vs estado carregado do servidor

Como buscar

# Wrappers silenciosos (catch sem toast)
search_files: "catch.*\{" em src/components/admin-
→ Se catch retorna { success: false } sem olpToast → 🔴 CRÍTICO

# invokeAction sem tratamento de resposta
search_files: "invokeAction\(" em src/components/
→ Se resultado não é verificado (.success) → 🔴 CRÍTICO

# Save sem rehydration
search_files: "Salvar permissões|salvarPermissoes" em src/components/
→ Após save, deve haver invalidateQueries/refetch do mesmo dado

Erros recorrentes

  • try { await invokeAction(...); return { success: true }; } catch { return { success: false }; } → sem toast = usuário não sabe se funcionou
  • Save bem-sucedido sem invalidar cache → UI mostra dado antigo até reload manual
  • Botão "Salvar" sempre visível (sem diff vs baseline) → confunde sobre o que mudou

11.7. Scoping de papel_id em gestao-usuarios-escola

O que verificar

  • [ ] 🔴 Todo INSERT em usuarios_escola_permissoes inclui papel_id — sem exceção
  • [ ] 🔴 Todo INSERT em usuarios_escola_sub_permissoes inclui papel_id — sem exceção
  • [ ] 🔴 Todo DELETE em permissões usa .eq("papel_id", ...) para escopo correto
  • [ ] 🔴 Todo SELECT de listagem filtra ou agrupa por papel_id do vínculo ativo
  • [ ] Seed de sub_permissoes: após criar/atualizar permissões, sub_permissões são semeadas via seedSubPermissoesForRole
  • [ ] papel_id é NOT NULL nas tabelas de permissões (constraint de banco ativa desde Abril/2026)

Arquivos afetados

supabase/functions/gestao-usuarios-escola/index.ts — 6 pontos de escrita
supabase/functions/_shared/gestao-usuarios-helpers.ts — 1 ponto de escrita (create)
supabase/functions/_shared/permissions.ts — resolveMenuForUser (leitura scoped)

Erros recorrentes

  • INSERT sem papel_id → registro órfão → /me não encontra → AccessBlockedScreen
  • DELETE sem papel_id → apaga permissões de TODOS os papéis → colisão multi-papel
  • Unique index com NULL → duplicatas ilimitadas (corrigido com NOT NULL constraint)

11.8. Canary Priority no CRUD de Permissões

Regras de negócio

CenárioAdmin vêEscola vê
Feature globalmente ONCheckbox editávelCheckbox editável
Feature globalmente OFF + canary ONCheckbox checked + disabled + badge "Canary"Invisível
Feature globalmente OFF sem canaryCheckbox unchecked + disabled + badge "Manutenção"Badge "Em manutenção"

O que verificar

  • [ ] 🔴 get_permissoes_usuario retorna canary_permissions[] e features_em_manutencao[]
  • [ ] 🔴 update_permissoes_usuario nunca deleta/sobrescreve permissões protegidas por canary
  • [ ] 🔴 Frontend admin: items canary são checked + disabled com badge "Canary"
  • [ ] 🔴 Frontend escola: items canary ficam completamente invisíveis (não renderizados)
  • [ ] 🔴 Frontend escola: items em manutenção mostram badge "Em manutenção"
  • [ ] temAlteracao() ignora items canary na comparação de diff
  • [ ] marcarTodas() / desmarcarTodas() preservam items canary
  • [ ] Save payload exclui items canary (backend os preserva independentemente)
  • [ ] checkCanaryAccess é exportada de feature-gate.ts e reutilizada no CRUD

Arquivos afetados

supabase/functions/admin-usuarios-escola/index.ts — get_permissoes_usuario + update_permissoes_usuario
supabase/functions/_shared/feature-gate.ts — checkCanaryAccess exportada
src/components/admin-permissoes-grid.tsx — renderização read-only + badges
src/hooks/useAdminUsuariosEscola.ts — tipo PermissoesUsuarioData

12. Enum / Data Integrity

O que verificar

  • [ ] Valores enviados pelo frontend correspondem exatamente ao enum do banco
  • [ ] Selects/dropdowns usam valores do enum (não strings hardcoded)
  • [ ] Tipos TypeScript do Supabase (types.ts) alinhados com código

Como buscar

# Enums usados no código vs banco
search_files: "tipo_atividade|status_escola|status_assinatura|tipo_escola" em src/
→ Cruzar valores com enums em src/integrations/supabase/types.ts

# Strings hardcoded que deveriam ser enum
search_files: "'evento'|'prova'|'atividade'" em src/components/
→ Verificar se valor existe no enum correspondente

Erros recorrentes

  • Frontend envia "evento" mas enum do banco é tipo_atividade_cronograma com valor "atividade" → insert falha silenciosamente com RLS
  • Select com opções hardcoded que não refletem enum atualizado → opções faltando ou inválidas

PARTE D — MANUTENIBILIDADE

13. Padrões de Código

O que verificar

  • [ ] invokeAction/invokeEdge para chamadas de edge function (nunca fetch direto)
  • [ ] Helpers centralizados (não duplicar lógica entre componentes)
  • [ ] Tipos exportados de arquivos dedicados (types/, helpers.ts)
  • [ ] Componentes >400 linhas extraídos em diretório por feature
  • [ ] Imports seguem padrão @/ (nunca ../../../)

Como buscar

# Fetch direto para edge functions
search_files: "fetch\(.*functions" em src/
→ Deve ser ZERO

# Componentes grandes
→ Verificar arquivos em src/components/ com >400 linhas
→ Candidatos para extração em diretório por feature

# Imports relativos profundos
search_files: "\.\./\.\./\.\." em src/
→ Substituir por @/ aliases

# Lógica duplicada
→ Funções de formatação (data, moeda, CPF) devem estar em lib/
→ Verificar se há formatDate/formatCurrency duplicado entre componentes

14. Nomenclatura

O que verificar

  • [ ] Banco: português snake_case
  • [ ] Edge Functions: português kebab-case com prefixo de domínio
  • [ ] React: inglês PascalCase, hooks use+Domínio
  • [ ] Labels UI: português
  • [ ] Logs: modulo.operacao lowercase

Ref: docs/development/NAMING_CONVENTIONS.md


15. Testes

O que verificar

  • [ ] Funções puras com lógica complexa têm testes unitários
  • [ ] Hooks críticos têm testes de integração
  • [ ] Fluxos E2E para funcionalidades de alto risco
  • [ ] Testes existentes ainda passam após mudanças
  • [ ] 🔴 Edge Function com Zod tem teste de parser PURO alimentado pelo body literal que invokeAction envia ({ action, params: {...} }). Mock de invokeAction no teste do hook não substitui — ele satisfaz a interface, mas nunca exercita o contrato real. Bug histórico: fatura_id: Required 2026-06-19 passou pelos testes de hook porque o invokeAction estava mockado. Ver PROBLEM_SOLVING Caso 21.
  • [ ] 🔴 Testes de hook usam UUIDs/IDs reais, não placeholders ('f1', 'fatura-1', 'xyz'). Placeholder esconde guards de validação client-side e mascara bugs de contrato.

Como buscar

# Funções sem teste
→ Listar arquivos em src/lib/ e verificar se há __tests__/ correspondente
→ Priorizar: helpers de cálculo, validação, formatação

# Testes quebrados
→ Rodar vitest para verificar

# Edge Function com Zod sem teste de parser puro
rg -l "safeParse|z\.object" supabase/functions/*/index.ts
→ Para cada match, verificar se existe __tests__/payload-contract.test.ts (ou equivalente)
→ Que rode o parser direto com o body literal de invokeAction

# Placeholders em vez de UUID
rg "'f1'|'fatura-1'|'aluno-1'|'id-1'" src/hooks/__tests__/
→ Trocar por UUID real (crypto.randomUUID() em fixture)

Ref: docs/development/TESTING_MASTER.md


PARTE E — DOCUMENTAÇÃO VIVA

16. Fidelidade da Documentação

O que verificar

Cada documento abaixo deve refletir fielmente o estado atual do código:

DocumentoVerificação
REACT_QUERY_CACHE.md §8Lista de hooks legados/migrados está atualizada?
CODING_STANDARDS.mdTodos os padrões em uso estão documentados?
EDGE_FUNCTIONS_CATALOG.mdTodas as edge functions existentes estão listadas?
DATABASE_SCHEMA.mdTabelas novas estão documentadas? Enums corretos? Functions listadas?
AUDIT_LOG.mdAções de log novas estão catalogadas?
NAVIGATION_AND_ROUTING.mdRotas e permissões atualizadas?
INCIDENT_POLICY.mdLista de ações monitoradas atualizada?
NOTIFICATIONS_MAP.mdNotificações novas mapeadas?

🔴 Itens OBRIGATÓRIOS em toda entrega com mudança de schema ou Edge Function

  • [ ] DATABASE_SCHEMA.md atualizado: Se criou/alterou tabela, enum ou database function → atualizar contagem e detalhamento
  • [ ] EDGE_FUNCTIONS_CATALOG.md atualizado: Se criou nova Edge Function → adicionar entrada + mapa Function↔Hook
  • [ ] Enums verificados: Valores na doc conferem com banco real (rodar query de enums se necessário)
  • [ ] Database Functions verificadas: Functions novas documentadas com tipo e descrição

Contexto: Em Abr/2026, auditoria revelou 19 tabelas, 10 enums e 9 functions ausentes da documentação — acumulados por meses de entregas sem atualização. Este checklist obrigatório previne reincidência.

Como verificar

# Edge functions não catalogadas
→ Listar diretórios em supabase/functions/
→ Cruzar com docs/architecture/EDGE_FUNCTIONS_CATALOG.md

# Ações de log não documentadas
search_files: "acao:" em supabase/functions/
→ Cruzar com docs/security/AUDIT_LOG.md e src/constants/log-actions.ts

# Tabelas não documentadas
→ Comparar tabelas em types.ts com docs/architecture/DATABASE_SCHEMA.md

# Hooks legados rastreados
search_files: "useState\(\[\]\)" em src/hooks/
→ Cruzar com lista em REACT_QUERY_CACHE.md §8

Resultado esperado

Ao final do @audit, reportar com severidade:

1. ✅ Conformes

Itens verificados que estão OK — listar por domínio.

2. 🟡 Atenção

Desvios menores que não bloqueiam mas devem ser corrigidos na sprint.

3. 🔴 Não-conformes

Itens críticos (segurança, perda de dados, falha silenciosa) que bloqueiam entrega.

4. 📝 Recomendações

Melhorias opcionais para backlog.


Apêndice: Tabela de Pesquisa Avançada

Referência rápida de patterns para busca sistemática de violações:

ViolaçãoPattern de buscaOndeSeveridade
Toast direto Sonnerfrom 'sonner'src/components/🔴
error.message crudescription: (err|error)\.messagesrc/🔴
alert()alert\(src/components/🔴
createClient inlinecreateClient\(supabase/functions/ (exceto supabase-client.ts)🔴
createSupabaseSystem indevidocreateSupabaseSystemsupabase/functions/ (exceto logging/auth/cron)🔴
service_role no frontendSERVICE_ROLEsrc/🔴
fetch direto p/ edgefetch\(.*functionssrc/🔴
Função de hook sem useCallbackreturn { funções sem useCallbacksrc/hooks/🔴
enabled com dependência circularenabled:.*!== null onde dado vem da própria querysrc/hooks/🔴
Permissão sem papel_idusuarios_escola_permissoes sem .eq('papel_id')supabase/functions/🔴
Save silencioso (sem toast)catch.*return.*success: false sem olpToastsrc/components/admin-🔴
invokeAction sem tratar .successinvokeAction( sem verificar resultadosrc/components/🔴
useState+useEffect legadouseState\(\[\]\)src/hooks/🟡
staleTime faltandouseQuery\(\{ sem staleTimesrc/hooks/🟡
queryKey incompletaqueryKey:src/hooks/🟡
Mutation sem invalidaçãouseMutation\(\{ sem invalidateQueriessrc/hooks/🟡
.in() sem chunking\.in\( com array >100supabase/functions/🟡
SELECT * desnecessário\.select\(\)supabase/functions/🟡
N+1 query em loopfor.*await.*\.from\(supabase/functions/🟡
queryFn que engole erroreturn [] dentro de queryFnsrc/🟡
3+ useQuery para mesmo backendmúltiplos useQuery no mesmo hook/componentesrc/🟡
useEffect redundante com React QueryuseEffect chamando refetch de useQuerysrc/components/🟡
CUD sem registrarLog\.insert\(|\.update\(|\.delete\( sem registrarLogsupabase/functions/🟡
req faltando em logregistrarLog sem reqsupabase/functions/🟡
Componente >400 linhascontagem de linhassrc/components/🟡
Import relativo profundo\.\./\.\./\.\.src/🟢
Enum hardcoded'evento'|'prova'src/components/🟡
Ação sem cadastroações em registrarLog vs log-actions.tscruzamento🟡
Doc desatualizadacruzamento código vs docs/vários🟡

Referências

  • docs/operations/INCIDENT_POLICY.md — Política de incidentes
  • docs/operations/PROD_BACKUP.mdSSOT do processo de backup manual de produção (cifrado, retenção 7d). Consumido por MIGRATION_SAFETY_PROTOCOL Fase 2.
  • docs/security/AUDIT_LOG.md — Catálogo de ações de log
  • docs/development/CODING_STANDARDS.md — Padrões de código
  • docs/development/NAMING_CONVENTIONS.md — Nomenclatura
  • docs/development/REACT_QUERY_CACHE.md — React Query e cache
  • docs/development/TESTING_MASTER.md — Estratégia de testes (documento mestre)
  • docs/architecture/EDGE_FUNCTIONS_CATALOG.md — Catálogo de Edge Functions
  • docs/architecture/DATABASE_SCHEMA.md — Schema do banco
  • docs/development/PROBLEM_SOLVING.md — Protocolo de resolução de problemas
  • src/lib/error-helpers.ts — Helper de erros amigáveis
  • supabase/functions/_shared/supabase-client.ts — Clientes Supabase

§12 — Cobertura de Testes: Permissões, Canary e Feature Flags

Adicionado: 2026-04-05 | Contexto: Auditoria completa do ecossistema

Inventário de testes (pós-auditoria)

ArquivoCenáriosTipo
_shared/__tests__/permissions.test.ts8Unitário Deno — resolveMenuForUser
_shared/__tests__/feature-gate.test.ts13Unitário Deno — requireFeatureFlag
_shared/__tests__/feature-gate-canary.test.ts5Unitário Deno — checkCanaryAccess isolado
_shared/__tests__/usuarios-helpers-seed.test.ts5Unitário Deno — seed/remove com papel_id
_shared/__tests__/usuarios-helpers.test.ts7Unitário Deno — normalizePapelVinculos
admin-usuarios-escola/__tests__/canary-priority.test.ts3Contrato HTTP — canary no CRUD
gestao-usuarios-escola/__tests__/papel-id-scoping.test.ts4Contrato HTTP — papel_id scoping
src/components/__tests__/AdminPermissoesGrid.test.tsx6Componente Vitest — canary/manutenção UI
src/hooks/__tests__/useAdminUsuariosEscola.canary.test.ts2Tipo Vitest — PermissoesUsuarioData
src/hooks/__tests__/useAdminUsuariosEscola.multipapel.test.ts2Isolamento multi-papel

Regras de cobertura mínima

  1. resolveMenuForUser — qualquer mudança DEVE ter teste unitário correspondente
  2. checkCanaryAccess — cenários edge (grupo inativo, múltiplos grupos) obrigatórios
  3. seedPermissionsForRole — verificar papel_id em todos os registros inseridos
  4. AdminPermissoesGrid — canary read-only + manutenção badge obrigatórios
  5. Canary priority — update_permissoes_usuario NUNCA remove permissões canary

Integridade de dados

  • Migration de seed executada: 20 vínculos órfãos corrigidos (0 restantes)
  • Sub-permissões populadas: 390 registros (antes: 0)
  • Total permissões: 246 (antes: 148)
  • Verificação: PAPEIS_COM_SEED inclui coordenador, diretor E escola

§17 — Disciplina de Feature Flags

Adicionado: 2026-04-22 | Contexto: formalização do ciclo de vida de feature flags SSOT: FEATURE_FLAGS_LIFECYCLE.md · Aplicado pelo @workflow §7

Verificações

  • [ ] 🔴 Toda edge function que serve uma feature opt-in chama requireFeatureFlag em todas as ações, incluindo leitura (list, list_active, get_*)
  • [ ] 🔴 Toda chave segue snake_case + parent_chave em sub-flags
  • [ ] 🔴 Sem flags duplicadas semanticamente (ex: cursos vs formacao)
  • [ ] 🔴 Gate referenciando flag existente no banco (gate fail-close gera 403 perpétuo se a flag não existir)
  • [ ] 🟡 Coluna categoria populada com valor canônico (admin|especialista|escola|coordenador|diretor|portal|outros) — não inferida
  • [ ] 🟡 Toda flag tem os 4 testes mínimos (backend ON, backend OFF, frontend gate, canary)
  • [ ] 🟡 Flag promovida de planejada para canary antes de global (exceto correção urgente)
  • [ ] 🟡 Flag em global há >90 dias sem incidente: marcar como candidata a aposentadoria
  • [ ] 🟢 Flags aposentadas removidas do banco junto com o gate no código

Como verificar

bash
# 1. Edge functions servindo feature sem requireFeatureFlag
search_files: "requireFeatureFlag" em supabase/functions/
 cruzar com flags listadas em feature_flags
 rodar scripts/audit/feature-flag-coverage.ts (relatório automatizado)

# 2. Gates frontend
search_files: "<FeatureGate" em src/
 cruzar com a mesma lista

# 3. Categoria não populada
SELECT chave FROM feature_flags WHERE categoria = 'outros';
 revisar: ou é genuinamente "outros" ou faltou classificar

# 4. Chaves em kebab-case (legado)
SELECT chave FROM feature_flags WHERE chave LIKE '%-%';
 planejar alias snake_case se feature for tocada

Referência cruzada

§11.8 (Canary Priority) reforça que funcionalidades liberadas via Canary são read-only no CRUD de permissões — ver §17 acima e a memória interna auth/school-manager-canary-protection (acessível via Lovable Memory, não exposta no VitePress).


Anti-padrões de CI (catalogados)

AP-CI-001 — Job HTTP-bound sem dependência do deploy do mesmo run

Sintoma típico: teste que faz fetch() contra STAGING_*_URL falha com "500 inesperado" ou "resposta antiga", mas o código em main está correto. Reexecutar o CI ⇒ passa. Diff entre as duas runs: zero código.

Causa: o job de teste depende apenas de lint-and-build, igual ao deploy-staging → ambos rodam em paralelo → o teste bate na versão ANTERIOR já deployada em staging, validando "código fantasma". A correção do commit atual só é validada no próximo run.

Como detectar:

bash
# Toda suíte que lê STAGING_*_URL precisa depender de deploy-staging
grep -l "STAGING_SUPABASE_URL\|STAGING_PROJECT_REF" .github/workflows/*.yml
# Para cada job que aparece: confirmar `needs:` inclui `deploy-staging`

Correção: declarar needs: [..., deploy-staging] em todo job HTTP-bound. unit-tests (sem rede) NÃO precisa.

Histórico:

  • 2026-05-20 — deno-tests quebrava notificacoes/security.test.ts::Admin create_batch → 200 com 500. Diagnóstico inicial atribuiu a deploy stale e adicionou needs: [..., deploy-staging]. Diagnóstico revisado em 2026-05-20 (mesmo dia): a dependência é necessária mas NÃO era a causa raiz — o 500 persistia mesmo com deploy fresco. Causa real catalogada em AP-RLS-003. Relato completo: docs/audits/AUDIT_CI_HEALTH_2026-05-20.md.

AP-CI-002 — test.skip em massa como "intenção documentada"

Sintoma: grep -c test.skip e2e/*.spec.ts retorna dezenas; ao remover .skip, a maioria passa trivialmente (corpo vazio async () => {}).

Por que é anti-padrão: o relatório do Playwright mostra "X passed" inflado por scaffolds sem assertion. Falsa confiança que mascara cobertura inexistente. Se outro dev remover .skip no futuro, vai parecer que ganhou cobertura de graça.

Regra:

  • Scaffold vazio (async () => {} sem statements) → deletar. Mover intent para um comentário/seção "Backlog" no fim do arquivo.
  • Test com assertions mas sem setup (login, seed) → usar test.fixme(...). Aparece como pendência explícita no relatório, não infla "passed".
  • Skip condicional (test.skip(!env, '...')) → manter; é guard legítimo de pré-condição.

Checklist @audit testes:

bash
# Skips totais devem ser apenas guards condicionais
grep -rn "test.skip" e2e/ | grep -v "test.skip(!"  # esperado: 0
# fixmes refletem backlog real
grep -rn "test.fixme" e2e/ | wc -l

Histórico:

  • 2026-05-20 — saneamento de 95 → 5 skips condicionais + 25 test.fixme em e2e/mural-coordenador.spec.ts, olimpiada-detalhes.spec.ts, portal-aluno-dashboard.spec.ts, portal-responsavel.spec.ts.

AP-RLS-003 — Troca silenciosa de supabaseSystem por supabase em action admin

Sintoma típico: action administrativa que CRIA linha para OUTRO usuário (ex: notificacoes.create_internal, notificacoes.create_batch, broadcast de alertas, seed por CRON) começa a devolver 500 Erro interno para o próprio admin. Produção pode esconder o bug se OLP_JWT_SECRET == SUPABASE_JWT_SECRET; staging com signing-keys assimétricas, JWT secret rotacionado ou principal_role com case divergente quebra.

Causa: o INSERT foi reescrito de supabaseSystem.from(...).insert(...) (service-role, bypass RLS — categoria 1 do ADR-011) para supabase.from(...).insert(...) (RLS ATIVA com cookie do usuário). A WITH CHECK passa a depender de cada partícula do JWT custom OLP ser interpretada por PostgREST como authenticated — qualquer divergência (secret, claim, case) → 42501 new row violates row-level security policy → handler mapeia para 500.

Por que isso não é "RLS bom é RLS sempre" — actions admin que escrevem em linhas de terceiros têm guard aplicacional canônico via requireRole(['administrador']). RLS é defesa em profundidade, NUNCA caminho primário desse tipo de ação. Forçar RLS aqui acopla a integridade da feature ao alinhamento de secrets entre dois sistemas, o que é frágil.

Como detectar:

bash
# Procurar INSERTs em handlers admin que NÃO usam supabaseSystem
rg -n 'requireRole\(.*administrador' supabase/functions/ -A 50 | rg 'supabase\.from\(.*\)\.insert'
# Cross-check: actions create_batch / create_internal / broadcast / seed devem usar supabaseSystem
rg -n 'create_internal|create_batch|broadcast|seed_' supabase/functions/ -A 20 | rg '\.insert\('

Correção:

  1. INSERT admin para outro usuário → createSupabaseSystem().from(...).insert(...), comentário citando ADR-011 cat.1 + requireRole que faz o guard.
  2. Manter a RLS policy *_admin_insert como defesa em profundidade (com lower(auth.jwt()->>'principal_role') para tolerar variação de case).
  3. Teste de segurança do cenário "admin consegue X → 200" SEMPRE com asserção positiva (assertEquals(status, 200) + payload), nunca status !== 403.

Histórico:

  • 2026-05-20 — notificacoes/create_batch e create_internal revertidos para supabaseSystem após refactor RLS-only quebrar security.test.ts em staging. Policy notificacoes_admin_insert normalizada com lower(...). Cenário 3 do teste reforçado para não mascarar 500.

AP-TEST-004 — Asserção negativa fraca mascara regressão

Sintoma: assertEquals(status !== 403, true) no teste de "admin consegue executar action". Aceita 200, 400, 500 indistintamente — qualquer regressão diferente de "guard de papel quebrado" passa silenciosamente.

Causa raiz: quando o autor do teste não tinha um usuario_id real para evitar FK violation, escolheu uma asserção que tolera 400. Mas ela tolera tudo que não seja 403 — incluindo 500 do próprio handler.

Regra:

  • Toda action de papel autorizado tem cenário "esperado-feliz" com assertEquals(status, EXPECTED_OK) + verificação mínima de payload.
  • Quando a action depende de um recurso real (FK), o teste descobre o recurso via list/lookup antes de chamar — não chuta UUID placeholder.
  • Asserção mínima de fallback: assertEquals(status !== 403, true) && assertEquals(status !== 500, true) (excluir 500 do conjunto aceito).

Histórico:

  • 2026-05-20 — notificacoes/security.test.ts::Cenário 3 reforçado: lookup de usuario_id real via list + asserção 200 quando disponível + proibição explícita de 500 no fallback.

Checklist @audit testes

Antes de aprovar mudança em .github/workflows/*.yml ou em qualquer *.spec.ts/__tests__/*.test.ts:

  1. Job que bate em URL de ambiente declara needs: [..., deploy-staging]? (AP-CI-001)
  2. Nenhum test.skip novo sem mensagem condicional? (AP-CI-002)
  3. Action admin que INSERT em linha de terceiro usa supabaseSystem? (AP-RLS-003)
  4. Teste de "papel autorizado consegue X" usa asserção positiva (status === EXPECTED_OK)? (AP-TEST-004)
  5. Test novo com test.fixme justificado no comentário do bloco?
  6. bun run lint + deno test + npx playwright test --list sem erros estruturais?

PARTE F — @audit feature <nome> (auditoria profunda de feature)

Escopo introduzido em 2026-06-04 para features data-heavy onde o @audit completo é raso demais (mapeamentos N:N, pipelines multi-camada, filtros silenciosos). SSOT: mem://audit/feature-deep-audit-pattern. Exemplo aplicado: AUDIT_RESULTADOS_NIVEL_S_2026-06-04.md.

Quando usar

  • Bug reportado de "dado some sem erro" ou "subset não aparece" (Nível S, turma X, perfil Y).
  • Feature com mapeamento N:N (olimpíadas↔níveis↔séries, escolas↔planos↔features).
  • Pipeline multi-camada UI → hook → edge fn → SQL com vários pontos de filtragem.
  • Antes de refator estrutural numa feature core.

Complementa, não substitui, completo/backend/segurança.

Estrutura obrigatória do documento

O audit deve viver em docs/audits/AUDIT_<FEATURE>_<YYYY-MM-DD>.md e seguir exatamente estas 8 (+1) seções:

#SeçãoConteúdo mínimo
0Sumário executivoTabela bug declarado → status → causa raiz provável → risco; lista de pontos de falha silenciosa identificados independente da causa raiz
1Mapa do pipelineDiagrama ASCII UI → hook → edge fn → SQL → tabela; cada nó de filtragem numerado (F1..Fn) com critério e indicação "falha silenciosa?"
2Inventário de dadosTabela com colunas, FKs e configs envolvidas — todas que tocam o pipeline
3Matriz de cenários≥10 casos cobrindo edge cases do domínio (perfis, modos, configs faltantes, dados parciais)
4Pontos de falha silenciosaPFS-1..PFS-n: onde o sistema descarta/zera sem alertar usuário/log
5Cobertura de testesAtual vs necessária — alimenta TESTING_BACKLOG.md
6Regras de negócio aplicáveisCitar memórias mem://* que regem o domínio
7Checklist SQL em prodQueries prontas para isolar cada hipótese (substituíveis por parâmetros)
8Hipóteses ranqueadasH1..Hn com probabilidade, query de confirmação, plano de correção condicional

Princípios não-negociáveis

  • 🔴 Auditoria não corrige código. Fix sai em PR separado, depois que a §7 confirma a hipótese em produção.
  • 🔴 Falha silenciosa é achado de primeira classe — listar mesmo que não seja a causa raiz do bug atual.
  • 🔴 Toda hipótese precisa de query SQL de confirmação — proibido "provavelmente é X".
  • 🟡 Numerar cada filtro do pipeline (F1..Fn) — facilita correlacionar §1 com §3, §4 e §8.
  • 🟡 Memórias novas só pós-correção — auditoria não cria memória de regra sem código que prove a regra.

Entregáveis

  • Doc em docs/audits/ + entry no sidebar (docs/.vitepress/config.ts).
  • Lacunas de teste promovidas em docs/development/TESTING_BACKLOG.md.
  • Eventuais atualizações em memórias pós-fix (não na auditoria).

Quando NÃO usar

  • Bug óbvio em 1 arquivo → use PROBLEM_SOLVING.md.
  • Validação pós-entrega genérica → @audit completo.
  • Suspeita de vulnerabilidade → @audit segurança.

Auditoria PR-6.5 — Formação · Hardening de contratos (Edge Functions)

Escopo: revalidar PR-1..PR-6 antes da PR-7 (docs finais). Foco em ADR-003 e ADR-018.

Estado encontrado

  • ✅ PR-1..PR-6 entregaram 79/79 testes verdes, typecheck limpo.
  • ✅ ADR-018 (Least Privilege) confirmado: Administrador sem SELECT em cursos*; Coordenador limitado a status='publicado' via RLS.
  • ⚠️ ADR-003 (body.params ?? body) não estava aplicado em 3 funções estendidas durante PR-2/4/5:
    • especialista-cursos — novas actions upload_imagem, update_status.
    • especialista-curso-videoscreate/update agora aceitam modulo_id (NOT NULL no banco após PR-1).
    • coordenador-videos — novas actions list_curso_completo, list_trilhas.
  • ⚠️ Validação de input nas novas actions era manual (if (!x) 400), sem Zod, divergindo do padrão de cursos-imagens-signed-url.

Correções aplicadas (mesma PR)

  1. Parser ADR-003 explícito nas 3 funções: extractParams(body) exportado + adotado no handler (raw → action + params).
  2. Schemas Zod exportados cobrindo o contrato novo:
    • CreateCursoSchema, UpdateCursoSchema, UploadImagemSchema, UpdateStatusSchema
    • CreateVideoSchema, UpdateVideoSchema (modulo_id como UUID quando explícito)
    • ListCursoCompletoSchema, ListTrilhasSchema
  3. Validação runtime ativada em upload_imagem e list_curso_completo (substituiu o if (!x) por safeParse com errors.flatten() no 400).
  4. 43 testes de parser (Deno, puros, sem net) cobrindo:
    • Happy path de cada schema.
    • Edge cases: campos ausentes, UUID inválido, enum fora do domínio, strings vazias, ordem zero/negativa, URL inválida.
    • Regressão Caso 21 ({action, params:{…}}) explícita em cada função.

Resultado

  • ✅ 43/43 parser tests verdes (deno test --allow-net --allow-env --allow-read . em supabase/functions/).
  • ✅ Contrato dual-format agora é parte do produto, não convenção tácita.
  • ✅ Próxima implementação (PR-7) parte de superfície de Edge Functions auditada.

Pendências assumidas para PR-7

  • Documentar tabela Action × Schema em EDGE_FUNCTIONS.md.
  • Adicionar integration test para FK RESTRICT em cursos_videos.modulo_id (precisa STAGING_* secrets, fica no track de security/integration).
  • Fechar roadmap em PROBLEM_SOLVING.md referenciando esta auditoria.

Auditoria PR-7 — Formação · Fechamento

Encerramento do roadmap Formação. Sem mudanças de runtime — apenas consolidação documental e snapshot de cobertura.

Verificações executadas

  • Constraints de banco (pg_constraint em produção): FK CASCADE em curso_modulos.curso_id, cursos_videos.curso_id, trilha_cursos.{trilha_id,curso_id}. FK RESTRICT em cursos_videos.modulo_id (apagar módulo com aula falha — comportamento esperado). UNIQUE (curso_id,ordem) em módulos e (modulo_id,ordem) em vídeos sustentam reorder two-phase. CHECKs (ordem>=1, avaliacao_media 0..5, contadores ≥0) ativos. Consolidado em docs/features/formacao/SCHEMA.md §Constraints & integridade.
  • ADR-018 — Least Privilege: matriz revisada. Administrador sem SELECT/CUD em cursos*/curso_modulos/trilhas*; Coordenador SELECT com status='publicado' via RLS; Especialista CUD escopado. Edge functions especialista-* exigem papel; coordenador-videos rejeita administrador.
  • ADR-003 — payload dual-format: extractParams(body) = body.params ?? body confirmado em especialista-cursos, especialista-curso-videos, coordenador-videos, cursos-imagens-signed-url. Regressão Caso 21 coberta por parser tests dedicados.
  • Cobertura de testes (snapshot PR-7):
    • Frontend Formação: 74/74 verdesbunx vitest run src/components/formacao src/hooks/formacao (13 arquivos).
    • Edge functions: 43/43 verdes — parser tests Deno em __tests__/parser.test.ts das 3 funções hardenizadas.
  • Docs build: docs/features/formacao/UI.md adicionado ao sidebar VitePress; SCHEMA/EDGE_FUNCTIONS/ROADMAP atualizados sem links mortos.

Achados / pendências controladas

  • Integration test FK RESTRICT em cursos_videos.modulo_id permanece no track de security/integration (requer STAGING_* secrets); não bloqueia fechamento porque a constraint já está ativa em produção e o frontend nunca expõe o caminho de delete-módulo-com-aula (UI obriga mover aulas primeiro). Registrado em docs/development/TESTING_BACKLOG.md.
  • Auto-avanço de aulas (proximaAula em helpers.ts) entregue como hook puro testado, sem UI consumindo — próximo ciclo, fora do escopo do roadmap atual.

Resultado

  • ✅ PR-1 → PR-7 fechados.
  • ✅ SSOT documental coeso: SCHEMA.md (dados) + EDGE_FUNCTIONS.md (contratos) + UI.md (apresentação) + ROADMAP.md (histórico).
  • ✅ Nenhuma regressão; suíte e typecheck verdes no momento do fechamento.

Caso 23 — Datas financeiras: drift de fuso e rolagem bancária (2026-06-30)

Sintomas

  1. Admin define Dia 5 → escola vê Dia 4. Premium Alfa mostrou 04/07, 04/08 etc. mesmo com a assinatura correta.
  2. Admin seleciona 05/07 (domingo) no modal de boleto → PDF sai com 06/07. Idem para sábado 04/07. PDFs idênticos (mesmo 065538011 na Safra), confirmando rolagem bancária para o próximo dia útil.

Raiz

  • Frontend: new Date('2026-07-05').toLocaleDateString('pt-BR') interpreta a string como meia-noite UTC; em BRT (UTC−3) vira 04/07/2026 21:00 e a UI exibe 04/07/2026. Vazamento ocorreu em pagamentos-escola.tsx, admin-escola-detalhes.tsx, admin-faturas-modal.tsx.
  • Backend: dataVencimento.toISOString().split('T')[0] e new Date(fatura.referencia_mes) em admin-assinaturas e escola-pagamentos produzem YYYY-MM-DD em UTC; em servidor com TZ ≠ UTC vira drift de 1 dia ao montar vencimento_em/referencia_mes.
  • Bancário (não-bug): Safra/Mercado Pago rolam vencimentos de fim de semana para o próximo dia útil no PDF. A data contratual do sistema continua correta; apenas o campo principal do boleto físico é deslocado.

Padrões obrigatórios (regra forward)

  1. UI financeira usa src/lib/date-only.ts (formatDateOnlyBR, formatMonthYearOnlyBR, isWeekendDateOnly, nextBusinessDateOnly, ymdFromParts). PROIBIDO new Date('YYYY-MM-DD').toLocaleDateString(...).
  2. Edge functions usam supabase/functions/_shared/billing-dates.ts (dueDateYmdForMonth, firstDayOfMonthYmd, addDaysYmd, addMonthsYmd, isWeekendYmd, nextBusinessDayYmd, parseYmd). PROIBIDO toISOString().split('T')[0] para montar campos DATE financeiros.
  3. Distinção contratual vs bancária: escola_faturas.vencimento_em mantém a data contratual; o log de pagamento.boleto_emitido* inclui vencimento_contratual, vencimento_bancario_estimado e fim_de_semana: true|false para auditoria. Quando a data cai em sáb/dom, o modal admin exibe aviso explicando que o PDF do banco pode mostrar o próximo dia útil.

Cobertura

  • src/lib/__tests__/date-only.test.ts — 5 grupos cobrindo: ausência de drift 2026-07-05 → 05/07/2026, fim de semana, rolagem, mês/ano em pt-BR, entrada inválida.
  • supabase/functions/_shared/__tests__/billing-dates.test.ts — 10 testes Deno (parse, último dia do mês, dueDate trunca para fim do mês, addDays/addMonths cruzando ano, weekend + nextBusinessDay).

Checklist financeiro (aplicar em PRs futuros)

  • [ ] Componente que renderiza DATE usa formatDateOnlyBR (não new Date(...)).
  • [ ] Edge function que escreve vencimento_em/referencia_mes usa helpers de billing-dates.ts.
  • [ ] Log de emissão de boleto inclui vencimento_contratual + vencimento_bancario_estimado + fim_de_semana.
  • [ ] Modal de seleção de data avisa quando a escolha cai em sáb/dom.

Sintomas

  1. Admin gera link público de cadastro em /admin/cadastros e envia para a escola por WhatsApp/e-mail. Algumas horas/dias depois (ainda dentro dos 7 dias de TTL e sem o destinatário ter aberto) o link retorna TOKEN_USED ("Este link já foi utilizado").
  2. Padrão correlato: links param de funcionar logo após o admin gerar um novo link para outra escola.
  3. Hipóteses descartadas: preview do WhatsApp/Outlook/antivírus não invalidamvalidate_token é read-only.

Raiz

supabase/functions/_shared/cadastro-handlers/generate-link.ts (versão pré-correção) executava, ao gerar qualquer novo link tipo=create:

ts
await supabase.from("cadastro_tokens")
  .update({ usado: true, usado_em: now })
  .eq("tipo", "create")
  .eq("usado", false)
  .eq("criado_por", user.id);   // ❌ filtra pelo EMISSOR, não pelo destinatário

Como links de criação não têm destinatário identificável no token (sem escola_id, sem e-mail, sem telefone), o único filtro era criado_por = admin. Cada novo link create do mesmo admin invalidava todos os links create pendentes do mesmo admin, independente da escola de destino.

Evidência em produção (consulta cadastro_tokens em 2026-06-30): admin 58b28dbf… gerou 7 links create em sequência; cada usado_em casa exatamente com o criado_em do próximo INSERT. Nenhum foi consumido por complete_signup.

Bug secundário no ramo update: o filtro .lt("expira_em", now + 365d) era sempre verdadeiro (pseudo-filtro inócuo); semanticamente correto revogar updates antigos da mesma escola, mas o código dava falsa impressão de filtragem.

Correção

  • Removido o bloco de revoga para tipo=create (links de criação são convites individuais e devem viver até expira_em ou serem consumidos por complete_signup).
  • Simplificado o bloco tipo=update para eq(escola_id) AND eq(tipo,update) AND eq(usado,false) — revoga apenas updates pendentes da MESMA escola, comportamento correto e explícito.
  • Lógica extraída para função pura revokePreviousTokens(supabase, tipo, escola_id) para teste unitário.

Padrão obrigatório (regra forward)

Revoga de token só pode usar identificador do DESTINATÁRIO (escola, contato, e-mail), nunca um identificador do EMISSOR (criado_por, criado_em). Se o token não carrega destinatário identificável, NÃO existe revoga implícita — o ciclo de vida é apenas expira_em + consumo.

Cobertura

  • supabase/functions/_shared/cadastro-handlers/__tests__/revoke-previous-tokens.test.ts — 4 testes Deno:
    1. create nunca dispara UPDATE (com ou sem escola_id).
    2. update sem escola_id é no-op.
    3. update revoga só a MESMA escola, com usado=false, payload {usado:true, usado_em}.
    4. Sentinela: nunca usar .eq("criado_por", …) nem .lt("expira_em", …) no filtro de revoga.

Critérios oficiais de invalidação de cadastro_tokens (SSOT)

  1. Expiração natural: expira_em < now() (TTL = 7 dias).
  2. Consumo: complete_signup bem-sucedido → usado=true, usado_em=now().
  3. Reemissão de update: novo link update para escola X invalida updates pendentes da escola X.
  4. Limpeza física (maintenance-croncleanup_cadastro_tokens): DELETE de linhas com expira_em < now() OU usado_em IS NOT NULL. Não invalida estado, apenas remove registros já inválidos.

Não-critérios: preview de URL (WhatsApp, Outlook SafeLinks, antivírus), múltiplos cliques do destinatário, geração de novo link create pelo mesmo admin.