@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ção | Quando usar | Seções a executar |
|---|---|---|
@audit (completo) | Antes de release / fim de feature grande | Partes A → E (todas) |
@audit backend | Mexi em Edge Function / migration / RLS | Parte A §1–§5, §8 + Parte C |
@audit frontend | Mexi em componente / hook / animação / UX visual | Parte B §6–§7, §10.7–§10.9 + → AUDIT_FRONTEND.md |
@audit segurança | Suspeita de vulnerabilidade / IDOR / leak / rodar Security Scan | Parte C completa (§11 → §11.8) + → AUDIT_SECURITY.md |
@audit perf | Tela lenta / loop / re-renders excessivos / mutação demora >500ms | Parte B §8.5 (régua de latência percebida — OBRIGATÓRIA primeiro) + §9–§10.9 |
@audit docs | Mudei schema/edge function — verificar SSOT | Parte 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)
- Backend (Edge Functions, shared helpers) → onde erros são mais críticos
- Frontend (hooks, componentes, lib/) → qualidade, UX e AUDIT_FRONTEND.md para visual
- 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ível | Significado | Ação |
|---|---|---|
| 🔴 Crítico | Segurança, perda de dados, falha silenciosa | Bloqueia entrega |
| 🟡 Atenção | Desvio de padrão, debt técnico | Corrigir na sprint |
| 🟢 Recomendação | Melhoria opcional | Backlog |
PARTE A — QUALIDADE DE CÓDIGO
1. Logging
O que verificar
- [ ] Todo Create/Update/Delete chama
registrarLog()comreq - [ ] Reads NÃO logam (nunca
registrarLogem SELECT) - [ ] Ação segue formato
<modulo>.<operacao>lowercase - [ ] Diff usa
gerarAlteracoes(antes, depois)para updates - [ ] Ação cadastrada em
src/constants/log-actions.ts - [ ]
reqpassado 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.tsErros recorrentes
reqfaltando noregistrarLog()→ geolocalização não funciona- Ação não cadastrada em
log-actions.ts→ filtro de logs quebrado gerarAlteracoesnã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.errorActionsouacoesExtras) - [ ] Fluxo normal do usuário NÃO está na lista de incidentes
- [ ]
detalhesdo log tem campo de erro acessível porextrairErroIncidente
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
- [ ]
useMyPermissionsfiltra sidebar corretamente - [ ] Novas permissões adicionadas ao enum
permissao_arease necessário - [ ] Ownership check: usuário só acessa dados da sua escola (
escola_iddo JWT) - [ ] 🔴 Permissões escopadas por
papel_id: toda leitura/escrita emusuarios_escola_permissoeseusuarios_escola_sub_permissoesDEVE filtrar porpapel_id— nunca sóusuario_id + escola_id - [ ] 🔴 Seed de permissões por vínculo: ao criar vínculo,
seedPermissionsForRolerecebepapel_idexplícito - [ ] 🔴 Remoção de permissões isolada: ao remover vínculo,
removePermissionsForRoledeleta APENAS permissões dopapel_idremovido — 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_permissoesestá 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âmetroErros 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_usuariosempapel_id→ retorna permissões do primeiro vínculo encontrado, não do badge selecionadoupdate_permissoes_usuariosempapel_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(), verificardata.length === 0ou usar.select()para confirmar - [ ] Sem uso de
service_roleondecreateSupabaseClient(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ÍTICOErros recorrentes
.update({}).eq('id', x)sem.select()→ RLS bloqueia silenciosamente, retorna{ data: null, error: null }createClientinline em edge function → bypassa padrão de segurançacreateSupabaseSystem()usado para operações de usuário → bypassa RLS
5. Edge Function — Estrutura
O que verificar
- [ ]
verify_jwt = falsenoconfig.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(nuncafetchdireto) - [ ] 🔴 Contrato
invokeAction↔ Zod: handler de action dispatch DEVE extrairconst rawParams = body.params ?? bodyANTES deSchema.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: Required2026-06-19 (ver PROBLEM_SOLVING Caso 21, ADR-003). - [ ] 🔴 Status HTTP para erro de provedor externo (MP, Wasender, Twilio): 4xx do provedor = 422 +
codesemântico + mensagem amigável. 5xx/timeout/network = 502 (upstream caiu de fato). NUNCA 502 indiscriminado para qualquer falha do provedor — o wrapperinvokeEdgetrata 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 emdocs/features/billing/boleto-manual.md§"Limites operacionais". - [ ] 🔴 Testes em
__tests__/: nenhum*.test.tsco-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 puroErros recorrentes
catchblock retornaResponsesemgetCorsHeaders(req)→ CORS error no browser- Frontend usa
fetch()direto → não passa cookies, auth falha extractAuthenticatedUserchamado antes de actions públicas → público não funciona- 🔴
Schema.safeParse(body)em handler invocado viainvokeAction→ 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: usarclassifyMpError()(ou equivalente por provedor) e mapear para 422 quando erro for de domínio. Pattern de busca:}, 502,emsupabase/functions/.
6. React Query — Migração
O que verificar
- [ ]
useQuerycomstaleTimedefinido (nunca default 0) - [ ]
queryKeyinclui todos os filtros/parâmetros relevantes - [ ] Mutations usam
invalidateQueriesnoonSuccess(ouonSettledno padrão Optimistic) - [ ] 🔴 Sem anti-padrão
useState([]) + useEffectpara fetch - [ ]
enabledusado quando query depende de parâmetro que pode ser undefined - [ ] 🔴
enabledNÃ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 → 🔴 deadlockTabela de staleTime por categoria
| Categoria | staleTime | Exemplos |
|---|---|---|
| Dashboards | 5 min | admin-dashboard-metrics |
| Listas CRUD | 5 min | alunos, turmas, usuários |
| Configurações | 10 min | escola-dados, mural-config |
| Dados quase estáticos | 30 min | olimpiadas, escolas-admin, papéis |
| Feature flags | 10 min | feature-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'— usarolpToastde@/components/ui/use-olp-toast - [ ] 🔴 Sem
error.messagecru em toasts — usargetUserFriendlyError(error) - [ ] 🔴 Sem
alert()— usarolpToast - [ ] Mensagens de erro genéricas para o usuário (sem stack traces, sem constraint names)
- [ ] Erros técnicos logados via
console.errorpara 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 exibirErros recorrentes
toast.error('Erro', { description: error.message })→ vaza constraint name do Postgresimport { 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 | nullno componente;isPendingglobal 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"comtitle=/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 omutationFndo hook deve re-checar (assertFaturaId). Zod no backend é segunda camada — usuário não pode ver"Payload inválido (fatura_id: Required)". - [ ]
disabledcoerente 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 invokeErros 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 localpendingId), 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 comfatura.id === undefined; backend retorna 400 cru.
8. Error Handling — Backend
O que verificar
- [ ] Todo
insert/update/deleteverifica{ 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
- [ ]
detalhesdo 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 throwTemplate de verificação
// ✅ 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 emset_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 emPROBLEM_SOLVING.mde ADR-016.
Definição de gatilho
| Gatilho | Definição | Exemplos |
|---|---|---|
| Singular | 1 click do usuário → 1 linha de dado editada | Toggle de checkbox, edição de 1 célula em modal, save de 1 campo |
| Lote pequeno | 1 click → ≤50 linhas editadas | Save de faixas de premiação, set de nota de corte para 1 nível |
| Lote grande | 1 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 | ≤500ms | 500ms–2s | >2s |
| Lote pequeno | ≤2s | 2s–5s | >5s |
| Lote grande | resposta imediata com task_id + polling | resposta síncrona ≤30s | resposta 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 emem://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/enviarAlertaPushno Response de mutação singular → mover paraEdgeRuntime.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
invalidateAllatrasado (~2.5s) quando backend usa recompute em background — UI mostra estado intermediário sem convergir.
Como auditar (passo a passo)
- 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.
- 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.
- 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 aEdgeRuntime.waitUntil. - 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.
- Cruzar com
PROBLEM_SOLVING.mdCaso 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 doreturn Response→ adiciona 150–400ms por log; mover paraEdgeRuntime.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
- [ ]
staleTimedefinido e compatível com a tabela da seção 6 - [ ]
refetchOnWindowFocusnão estátruesem motivo (React Query v5 default étrue) - [ ] Queries com
enabled: falsequando parâmetros são undefined/null - [ ] Sem queries duplicadas (mesmo dado buscado por hooks diferentes)
- [ ]
placeholderDataouinitialDatausado 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 dado10. 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.allpara 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_initOverhead 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
| Tela | Action | Consolida |
|---|---|---|
| Resultados | batch_init_resultados | listagem + stats + stats_agregadas |
| Mural | batch_init | listagem + olimpíadas + liberações + config |
10.6 N+1 Query Detection
O que verificar
- [ ] 🟡 Sem
.from('tabela').select()dentro defor/map/forEachem Edge Functions - [ ] Substituído por
.in('campo', arrayDeIds)comqueryInChunksse >100 IDs - [ ]
Promise.allusado para queries independentes (nãoawaitsequencial 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
// ❌ 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
useEffectDEVEM teruseCallback - [ ] Dependências do
useCallbacksão estáveis (queryClient, mutation, refetch — não dados reativos) - [ ]
useEffectde mount redundante removido quando React Query já auto-carrega - [ ] Sem
useEffectcom 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 → removerPadrã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&& !dataem componentes com cache pré-populado (batch_init, setQueryData) - [ ] 🔴 useEffect derivado:
useEffectque chamasetState/handleChangepara campos derivados (ex:termino = inicio + duracao) - [ ] 🟡 Radix wrapper conflict: Primitivas Radix (SelectItem, DropdownMenuItem) envolvidas em outras primitivas Radix (Tooltip, Popover)
- [ ] 🟡 Memoização quebrada:
React.memoem componente cujos props mudam desnecessariamente (callbacks instáveis, objetos recriados) - [ ] 🟡 Invalidação granular: Mutations que invalidam
queryKeys.allquando só precisam invalidarqueryKeys.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.subKeyErros recorrentes
if (isLoading) return <Skeleton />com batch_init → flash de 1 frame ao trocar tab (Caso 8 de PROBLEM_SOLVING.md)useEffectque calculahorario_terminochamandohandleChange→ invalidaReact.memode todas as rows (Caso 9)TooltipTriggerenvolvendoSelectItem→ 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ãosetTimeout/setInterval) - [ ] 🔴
prefers-reduced-motiontem 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
transitioncombinado comtransformcalculado por rAF (race condition garantida) - [ ] 🔴 Early-return de
prefers-reduced-motionNÃO renderiza JSX com transform estático intermediário (parece bug travado) - [ ] 🟡
isolateno container quando ház-indexinterno + 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` documentadoErros recorrentes
- Reescrever animação antes de inspecionar DOM no DevTools → 3 reescritas inúteis (ver Caso 12)
prefers-reduced-motionretorna JSX comtranslateX(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 300msem 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_rolekey no frontend - [ ] Rate-limits em endpoints públicos sensíveis (OTP, cadastro, lookup)
- [ ] Ownership check:
escola_iddo 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, lookup11.5. IDOR e Escalação de Privilégios
O que verificar
- [ ] 🔴 Mutações (UPDATE/DELETE) filtram por
escola_iddo JWT — não apenas poriddo 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
requireRolecom 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
idno body → backend usa apenas.eq("id", id)sem verificarescola_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.successe mostramolpToast - [ ] 🔴 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 dadoErros 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_permissoesincluipapel_id— sem exceção - [ ] 🔴 Todo INSERT em
usuarios_escola_sub_permissoesincluipapel_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_iddo 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 →
/menã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ário | Admin vê | Escola vê |
|---|---|---|
| Feature globalmente ON | Checkbox editável | Checkbox editável |
| Feature globalmente OFF + canary ON | Checkbox checked + disabled + badge "Canary" | Invisível |
| Feature globalmente OFF sem canary | Checkbox unchecked + disabled + badge "Manutenção" | Badge "Em manutenção" |
O que verificar
- [ ] 🔴
get_permissoes_usuarioretornacanary_permissions[]efeatures_em_manutencao[] - [ ] 🔴
update_permissoes_usuarionunca deleta/sobrescreve permissões protegidas por canary - [ ] 🔴 Frontend admin: items canary são
checked + disabledcom 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 defeature-gate.tse 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 PermissoesUsuarioData12. 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 correspondenteErros recorrentes
- Frontend envia
"evento"mas enum do banco étipo_atividade_cronogramacom 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/invokeEdgepara 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 componentes14. Nomenclatura
O que verificar
- [ ] Banco: português
snake_case - [ ] Edge Functions: português
kebab-casecom prefixo de domínio - [ ] React: inglês
PascalCase, hooksuse+Domínio - [ ] Labels UI: português
- [ ] Logs:
modulo.operacaolowercase
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
invokeActionenvia ({ action, params: {...} }). Mock deinvokeActionno teste do hook não substitui — ele satisfaz a interface, mas nunca exercita o contrato real. Bug histórico:fatura_id: Required2026-06-19 passou pelos testes de hook porque oinvokeActionestava 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:
| Documento | Verificação |
|---|---|
REACT_QUERY_CACHE.md §8 | Lista de hooks legados/migrados está atualizada? |
CODING_STANDARDS.md | Todos os padrões em uso estão documentados? |
EDGE_FUNCTIONS_CATALOG.md | Todas as edge functions existentes estão listadas? |
DATABASE_SCHEMA.md | Tabelas novas estão documentadas? Enums corretos? Functions listadas? |
AUDIT_LOG.md | Ações de log novas estão catalogadas? |
NAVIGATION_AND_ROUTING.md | Rotas e permissões atualizadas? |
INCIDENT_POLICY.md | Lista de ações monitoradas atualizada? |
NOTIFICATIONS_MAP.md | Notificaçõ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 §8Resultado 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ção | Pattern de busca | Onde | Severidade |
|---|---|---|---|
| Toast direto Sonner | from 'sonner' | src/components/ | 🔴 |
| error.message cru | description: (err|error)\.message | src/ | 🔴 |
| alert() | alert\( | src/components/ | 🔴 |
| createClient inline | createClient\( | supabase/functions/ (exceto supabase-client.ts) | 🔴 |
| createSupabaseSystem indevido | createSupabaseSystem | supabase/functions/ (exceto logging/auth/cron) | 🔴 |
| service_role no frontend | SERVICE_ROLE | src/ | 🔴 |
| fetch direto p/ edge | fetch\(.*functions | src/ | 🔴 |
| Função de hook sem useCallback | return { funções sem useCallback | src/hooks/ | 🔴 |
| enabled com dependência circular | enabled:.*!== null onde dado vem da própria query | src/hooks/ | 🔴 |
| Permissão sem papel_id | usuarios_escola_permissoes sem .eq('papel_id') | supabase/functions/ | 🔴 |
| Save silencioso (sem toast) | catch.*return.*success: false sem olpToast | src/components/admin- | 🔴 |
| invokeAction sem tratar .success | invokeAction( sem verificar resultado | src/components/ | 🔴 |
| useState+useEffect legado | useState\(\[\]\) | src/hooks/ | 🟡 |
| staleTime faltando | useQuery\(\{ sem staleTime | src/hooks/ | 🟡 |
| queryKey incompleta | queryKey: | src/hooks/ | 🟡 |
| Mutation sem invalidação | useMutation\(\{ sem invalidateQueries | src/hooks/ | 🟡 |
| .in() sem chunking | \.in\( com array >100 | supabase/functions/ | 🟡 |
| SELECT * desnecessário | \.select\(\) | supabase/functions/ | 🟡 |
| N+1 query em loop | for.*await.*\.from\( | supabase/functions/ | 🟡 |
| queryFn que engole erro | return [] dentro de queryFn | src/ | 🟡 |
| 3+ useQuery para mesmo backend | múltiplos useQuery no mesmo hook/componente | src/ | 🟡 |
| useEffect redundante com React Query | useEffect chamando refetch de useQuery | src/components/ | 🟡 |
| CUD sem registrarLog | \.insert\(|\.update\(|\.delete\( sem registrarLog | supabase/functions/ | 🟡 |
| req faltando em log | registrarLog sem req | supabase/functions/ | 🟡 |
| Componente >400 linhas | contagem de linhas | src/components/ | 🟡 |
| Import relativo profundo | \.\./\.\./\.\. | src/ | 🟢 |
| Enum hardcoded | 'evento'|'prova' | src/components/ | 🟡 |
| Ação sem cadastro | ações em registrarLog vs log-actions.ts | cruzamento | 🟡 |
| Doc desatualizada | cruzamento código vs docs/ | vários | 🟡 |
Referências
docs/operations/INCIDENT_POLICY.md— Política de incidentesdocs/operations/PROD_BACKUP.md— SSOT do processo de backup manual de produção (cifrado, retenção 7d). Consumido porMIGRATION_SAFETY_PROTOCOLFase 2.docs/security/AUDIT_LOG.md— Catálogo de ações de logdocs/development/CODING_STANDARDS.md— Padrões de códigodocs/development/NAMING_CONVENTIONS.md— Nomenclaturadocs/development/REACT_QUERY_CACHE.md— React Query e cachedocs/development/TESTING_MASTER.md— Estratégia de testes (documento mestre)docs/architecture/EDGE_FUNCTIONS_CATALOG.md— Catálogo de Edge Functionsdocs/architecture/DATABASE_SCHEMA.md— Schema do bancodocs/development/PROBLEM_SOLVING.md— Protocolo de resolução de problemassrc/lib/error-helpers.ts— Helper de erros amigáveissupabase/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)
| Arquivo | Cenários | Tipo |
|---|---|---|
_shared/__tests__/permissions.test.ts | 8 | Unitário Deno — resolveMenuForUser |
_shared/__tests__/feature-gate.test.ts | 13 | Unitário Deno — requireFeatureFlag |
_shared/__tests__/feature-gate-canary.test.ts | 5 | Unitário Deno — checkCanaryAccess isolado |
_shared/__tests__/usuarios-helpers-seed.test.ts | 5 | Unitário Deno — seed/remove com papel_id |
_shared/__tests__/usuarios-helpers.test.ts | 7 | Unitário Deno — normalizePapelVinculos |
admin-usuarios-escola/__tests__/canary-priority.test.ts | 3 | Contrato HTTP — canary no CRUD |
gestao-usuarios-escola/__tests__/papel-id-scoping.test.ts | 4 | Contrato HTTP — papel_id scoping |
src/components/__tests__/AdminPermissoesGrid.test.tsx | 6 | Componente Vitest — canary/manutenção UI |
src/hooks/__tests__/useAdminUsuariosEscola.canary.test.ts | 2 | Tipo Vitest — PermissoesUsuarioData |
src/hooks/__tests__/useAdminUsuariosEscola.multipapel.test.ts | 2 | Isolamento multi-papel |
Regras de cobertura mínima
- resolveMenuForUser — qualquer mudança DEVE ter teste unitário correspondente
- checkCanaryAccess — cenários edge (grupo inativo, múltiplos grupos) obrigatórios
- seedPermissionsForRole — verificar papel_id em todos os registros inseridos
- AdminPermissoesGrid — canary read-only + manutenção badge obrigatórios
- 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_SEEDinclui 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
requireFeatureFlagem todas as ações, incluindo leitura (list,list_active,get_*) - [ ] 🔴 Toda chave segue
snake_case+parent_chaveem sub-flags - [ ] 🔴 Sem flags duplicadas semanticamente (ex:
cursosvsformacao) - [ ] 🔴 Gate referenciando flag existente no banco (gate fail-close gera 403 perpétuo se a flag não existir)
- [ ] 🟡 Coluna
categoriapopulada 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
planejadaparacanaryantes deglobal(exceto correção urgente) - [ ] 🟡 Flag em
globalhá >90 dias sem incidente: marcar como candidata a aposentadoria - [ ] 🟢 Flags aposentadas removidas do banco junto com o gate no código
Como verificar
# 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 tocadaReferê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:
# 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-testsquebravanotificacoes/security.test.ts::Admin create_batch → 200com 500. Diagnóstico inicial atribuiu a deploy stale e adicionouneeds: [..., 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:
# 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 -lHistórico:
- 2026-05-20 — saneamento de 95 → 5 skips condicionais + 25
test.fixmeeme2e/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:
# 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:
- INSERT admin para outro usuário →
createSupabaseSystem().from(...).insert(...), comentário citando ADR-011 cat.1 +requireRoleque faz o guard. - Manter a RLS policy
*_admin_insertcomo defesa em profundidade (comlower(auth.jwt()->>'principal_role')para tolerar variação de case). - Teste de segurança do cenário "admin consegue X → 200" SEMPRE com asserção positiva (
assertEquals(status, 200)+ payload), nuncastatus !== 403.
Histórico:
- 2026-05-20 —
notificacoes/create_batchecreate_internalrevertidos parasupabaseSystemapós refactor RLS-only quebrarsecurity.test.tsem staging. Policynotificacoes_admin_insertnormalizada comlower(...). 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 3reforçado: lookup deusuario_idreal vialist+ 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:
- Job que bate em URL de ambiente declara
needs: [..., deploy-staging]? (AP-CI-001) - Nenhum
test.skipnovo sem mensagem condicional? (AP-CI-002) - Action admin que INSERT em linha de terceiro usa
supabaseSystem? (AP-RLS-003) - Teste de "papel autorizado consegue X" usa asserção positiva (
status === EXPECTED_OK)? (AP-TEST-004) - Test novo com
test.fixmejustificado no comentário do bloco? bun run lint+deno test+npx playwright test --listsem 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ção | Conteúdo mínimo |
|---|---|---|
| 0 | Sumário executivo | Tabela bug declarado → status → causa raiz provável → risco; lista de pontos de falha silenciosa identificados independente da causa raiz |
| 1 | Mapa do pipeline | Diagrama ASCII UI → hook → edge fn → SQL → tabela; cada nó de filtragem numerado (F1..Fn) com critério e indicação "falha silenciosa?" |
| 2 | Inventário de dados | Tabela com colunas, FKs e configs envolvidas — todas que tocam o pipeline |
| 3 | Matriz de cenários | ≥10 casos cobrindo edge cases do domínio (perfis, modos, configs faltantes, dados parciais) |
| 4 | Pontos de falha silenciosa | PFS-1..PFS-n: onde o sistema descarta/zera sem alertar usuário/log |
| 5 | Cobertura de testes | Atual vs necessária — alimenta TESTING_BACKLOG.md |
| 6 | Regras de negócio aplicáveis | Citar memórias mem://* que regem o domínio |
| 7 | Checklist SQL em prod | Queries prontas para isolar cada hipótese (substituíveis por parâmetros) |
| 8 | Hipóteses ranqueadas | H1..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 astatus='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 actionsupload_imagem,update_status.especialista-curso-videos—create/updateagora aceitammodulo_id(NOT NULL no banco após PR-1).coordenador-videos— novas actionslist_curso_completo,list_trilhas.
- ⚠️ Validação de input nas novas actions era manual (
if (!x) 400), sem Zod, divergindo do padrão decursos-imagens-signed-url.
Correções aplicadas (mesma PR)
- Parser ADR-003 explícito nas 3 funções:
extractParams(body)exportado + adotado no handler (raw → action + params). - Schemas Zod exportados cobrindo o contrato novo:
CreateCursoSchema,UpdateCursoSchema,UploadImagemSchema,UpdateStatusSchemaCreateVideoSchema,UpdateVideoSchema(modulo_id como UUID quando explícito)ListCursoCompletoSchema,ListTrilhasSchema
- Validação runtime ativada em
upload_imagemelist_curso_completo(substituiu oif (!x)porsafeParsecomerrors.flatten()no 400). - 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 .emsupabase/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 × SchemaemEDGE_FUNCTIONS.md. - Adicionar integration test para FK RESTRICT em
cursos_videos.modulo_id(precisaSTAGING_*secrets, fica no track de security/integration). - Fechar roadmap em
PROBLEM_SOLVING.mdreferenciando 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_constraintem produção): FK CASCADE emcurso_modulos.curso_id,cursos_videos.curso_id,trilha_cursos.{trilha_id,curso_id}. FK RESTRICT emcursos_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 emdocs/features/formacao/SCHEMA.md §Constraints & integridade. - ADR-018 — Least Privilege: matriz revisada. Administrador sem SELECT/CUD em
cursos*/curso_modulos/trilhas*; Coordenador SELECT comstatus='publicado'via RLS; Especialista CUD escopado. Edge functionsespecialista-*exigem papel;coordenador-videosrejeitaadministrador. - ADR-003 — payload dual-format:
extractParams(body) = body.params ?? bodyconfirmado emespecialista-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 verdes —
bunx vitest run src/components/formacao src/hooks/formacao(13 arquivos). - Edge functions: 43/43 verdes — parser tests Deno em
__tests__/parser.test.tsdas 3 funções hardenizadas.
- Frontend Formação: 74/74 verdes —
- Docs build:
docs/features/formacao/UI.mdadicionado ao sidebar VitePress; SCHEMA/EDGE_FUNCTIONS/ROADMAP atualizados sem links mortos.
Achados / pendências controladas
- Integration test FK RESTRICT em
cursos_videos.modulo_idpermanece no track de security/integration (requerSTAGING_*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 emdocs/development/TESTING_BACKLOG.md. - Auto-avanço de aulas (
proximaAulaemhelpers.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
- Admin define
Dia 5→ escola vêDia 4. Premium Alfa mostrou04/07,04/08etc. mesmo com a assinatura correta. - Admin seleciona 05/07 (domingo) no modal de boleto → PDF sai com 06/07. Idem para sábado 04/07. PDFs idênticos (mesmo
065538011na 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) vira04/07/2026 21:00e a UI exibe04/07/2026. Vazamento ocorreu empagamentos-escola.tsx,admin-escola-detalhes.tsx,admin-faturas-modal.tsx. - Backend:
dataVencimento.toISOString().split('T')[0]enew Date(fatura.referencia_mes)emadmin-assinaturaseescola-pagamentosproduzemYYYY-MM-DDem UTC; em servidor com TZ ≠ UTC vira drift de 1 dia ao montarvencimento_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)
- UI financeira usa
src/lib/date-only.ts(formatDateOnlyBR,formatMonthYearOnlyBR,isWeekendDateOnly,nextBusinessDateOnly,ymdFromParts). PROIBIDOnew Date('YYYY-MM-DD').toLocaleDateString(...). - Edge functions usam
supabase/functions/_shared/billing-dates.ts(dueDateYmdForMonth,firstDayOfMonthYmd,addDaysYmd,addMonthsYmd,isWeekendYmd,nextBusinessDayYmd,parseYmd). PROIBIDOtoISOString().split('T')[0]para montar camposDATEfinanceiros. - Distinção contratual vs bancária:
escola_faturas.vencimento_emmantém a data contratual; o log depagamento.boleto_emitido*incluivencimento_contratual,vencimento_bancario_estimadoefim_de_semana: true|falsepara 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 drift2026-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
DATEusaformatDateOnlyBR(nãonew Date(...)). - [ ] Edge function que escreve
vencimento_em/referencia_mesusa helpers debilling-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.
Caso 24 — Invalidação prematura de links de cadastro (2026-06-30)
Sintomas
- Admin gera link público de cadastro em
/admin/cadastrose 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 retornaTOKEN_USED("Este link já foi utilizado"). - Padrão correlato: links param de funcionar logo após o admin gerar um novo link para outra escola.
- Hipóteses descartadas: preview do WhatsApp/Outlook/antivírus não invalidam —
validate_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:
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árioComo 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_emou serem consumidos porcomplete_signup). - Simplificado o bloco
tipo=updateparaeq(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 é apenasexpira_em+ consumo.
Cobertura
supabase/functions/_shared/cadastro-handlers/__tests__/revoke-previous-tokens.test.ts— 4 testes Deno:createnunca dispara UPDATE (com ou sem escola_id).updatesem escola_id é no-op.updaterevoga só a MESMA escola, comusado=false, payload{usado:true, usado_em}.- Sentinela: nunca usar
.eq("criado_por", …)nem.lt("expira_em", …)no filtro de revoga.
Critérios oficiais de invalidação de cadastro_tokens (SSOT)
- Expiração natural:
expira_em < now()(TTL = 7 dias). - Consumo:
complete_signupbem-sucedido →usado=true,usado_em=now(). - Reemissão de update: novo link
updatepara escola X invalida updates pendentes da escola X. - Limpeza física (
maintenance-cron→cleanup_cadastro_tokens): DELETE de linhas comexpira_em < now()OUusado_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.