Template: Nova Edge Function
Checklist Completo
markdown
□ Arquivo criado em supabase/functions/<nome>/index.ts
□ ⚠️ **BLOQUEANTE** — Entry adicionada em supabase/config.toml com verify_jwt = false (sem isso a função usa o default global e pode rejeitar requests)
□ Importa cors-helpers, auth-helpers, supabase-client, logging-helper, sms-templates (se enviar mensagens)
□ Trata OPTIONS no início: handleCorsPrelight(req)
□ corsHeaders incluído em TODAS as respostas (sucesso E erro)
□ Ações públicas definidas ANTES do auth check
□ Ações autenticadas após extractAuthenticatedUser()
□ Verificação de papel quando necessário
□ registrarLog() chamado para operações de escrita com req: req
□ Retorna sempre { success, data } ou { success, message }Template Completo
typescript
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { getCorsHeaders, handleCorsPrelight } from "../_shared/cors-helpers.ts";
import { extractAuthenticatedUser, type AuthenticatedUser } from "../_shared/auth-helpers.ts";
import { createSupabaseClient } from "../_shared/supabase-client.ts";
import { createSupabasePublic } from "../_shared/supabase-client.ts";
import { registrarLog, extractIP } from "../_shared/logging-helper.ts";
import { gerarAlteracoes } from "../_shared/diff-helper.ts";
serve(async (req) => {
// ============================================
// 1. CORS preflight (SEMPRE primeiro)
// ============================================
if (req.method === "OPTIONS") {
return handleCorsPrelight(req);
}
const corsHeaders = getCorsHeaders(req);
try {
const { action, params } = await req.json();
// ============================================
// 2. AÇÕES PÚBLICAS (ANTES do auth check)
// ============================================
if (action === "list_public") {
const supabasePublic = createSupabasePublic();
const { data, error } = await supabasePublic
.from("tabela")
.select("*")
.eq("ativo", true);
if (error) throw error;
return new Response(
JSON.stringify({ success: true, data }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// ============================================
// 3. AUTENTICAÇÃO (tudo abaixo requer login)
// ============================================
let user: AuthenticatedUser;
try {
user = await extractAuthenticatedUser(req);
} catch (authError) {
return new Response(
JSON.stringify({ success: false, message: "Não autenticado" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// ============================================
// 4. VERIFICAÇÃO DE PAPEL (se necessário)
// ============================================
if (!["escola", "coordenador"].includes(user.principal_role)) {
return new Response(
JSON.stringify({ success: false, message: "Acesso negado." }),
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// ============================================
// 5. AÇÃO: LIST (leitura — sem log)
// ============================================
const supabase = createSupabaseClient(req);
if (action === "list") {
const { data, error } = await supabase
.from("tabela")
.select("*")
.eq("escola_id", user.escola_id)
.order("criado_em", { ascending: false });
if (error) throw error;
return new Response(
JSON.stringify({ success: true, data }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// ============================================
// 6. AÇÃO: CREATE (escrita — com log)
// ============================================
if (action === "create") {
const { nome } = params || {};
if (!nome) {
return new Response(
JSON.stringify({ success: false, message: "Nome é obrigatório." }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data, error } = await supabase
.from("tabela")
.insert({
nome,
escola_id: user.escola_id,
criado_por: user.id,
})
.select()
.single();
if (error) throw error;
// LOG OBRIGATÓRIO
await registrarLog({
usuarioId: user.id,
nomeUsuario: user.nome_completo,
papelPrincipal: user.principal_role,
ip: extractIP(req),
acao: "modulo.create",
detalhes: {
tipo: "create",
entidade: "tabela",
entidadeId: data.id,
resumo: { nome: data.nome },
},
req: req, // OBRIGATÓRIO para geolocalização
});
return new Response(
JSON.stringify({ success: true, data }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// ============================================
// 7. AÇÃO: UPDATE (escrita — com log + diff)
// ============================================
if (action === "update") {
const { id, ...dados } = params || {};
if (!id) {
return new Response(
JSON.stringify({ success: false, message: "ID é obrigatório." }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Buscar ANTES do update (para diff)
const { data: antes } = await supabase
.from("tabela")
.select("*")
.eq("id", id)
.eq("escola_id", user.escola_id)
.single();
if (!antes) {
return new Response(
JSON.stringify({ success: false, message: "Registro não encontrado." }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: depois, error } = await supabase
.from("tabela")
.update(dados)
.eq("id", id)
.eq("escola_id", user.escola_id)
.select()
.single();
if (error) throw error;
// Gerar diff e logar
const alteracoes = gerarAlteracoes(antes, depois, ["id", "criado_em", "atualizado_em"]);
await registrarLog({
usuarioId: user.id,
nomeUsuario: user.nome_completo,
papelPrincipal: user.principal_role,
ip: extractIP(req),
acao: "modulo.update",
detalhes: {
tipo: "update",
entidade: "tabela",
entidadeId: id,
alteracoes,
},
req: req,
});
return new Response(
JSON.stringify({ success: true, data: depois }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// ============================================
// 8. AÇÃO: DELETE / SOFT DELETE (com log)
// ============================================
if (action === "delete") {
const { id } = params || {};
if (!id) {
return new Response(
JSON.stringify({ success: false, message: "ID é obrigatório." }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Soft delete (preferível)
const { error } = await supabase
.from("tabela")
.update({ ativo: false })
.eq("id", id)
.eq("escola_id", user.escola_id);
if (error) throw error;
await registrarLog({
usuarioId: user.id,
nomeUsuario: user.nome_completo,
papelPrincipal: user.principal_role,
ip: extractIP(req),
acao: "modulo.soft_delete",
detalhes: {
tipo: "soft_delete",
entidade: "tabela",
entidadeId: id,
statusAnterior: "ativo",
statusNovo: "inativo",
},
req: req,
});
return new Response(
JSON.stringify({ success: true, message: "Removido com sucesso." }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// ============================================
// 9. AÇÃO NÃO RECONHECIDA
// ============================================
return new Response(
JSON.stringify({ success: false, message: `Ação não reconhecida: ${action}` }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Erro na edge function:", error);
return new Response(
JSON.stringify({ success: false, message: "Erro interno do servidor." }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});Configuração no supabase/config.toml
Adicionar ao final do arquivo:
toml
[functions.minha-nova-funcao]
verify_jwt = falseNota:
verify_jwt = falseé padrão global no projeto. A autenticação é feita em código via_shared/auth-helpers.ts(cookie HttpOnly).
Hook Frontend Correspondente
Template completo:
docs/development/NEW_HOOK.md
typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '@/contexts/auth-context';
import { invokeAction } from '@/lib/edge-function';
import { olpToast } from '@/components/ui/use-olp-toast';
import { getUserFriendlyError } from '@/lib/error-helpers';
interface MeuItem {
id: string;
nome: string;
}
export function useMeuHook() {
const { isAuthenticated, papelPrincipal } = useAuth();
const queryClient = useQueryClient();
const { data: items = [], isLoading } = useQuery<MeuItem[]>({
queryKey: ['meu-modulo'],
queryFn: async () => {
const result = await invokeAction('minha-nova-funcao', 'list', {});
if (!result.success) throw new Error(result.message || 'Erro ao carregar');
return result.data || [];
},
enabled: isAuthenticated && papelPrincipal === 'papel_requerido',
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
});
const createMutation = useMutation({
mutationFn: async (dados: Omit<MeuItem, 'id'>) => {
const result = await invokeAction('minha-nova-funcao', 'create', dados);
if (!result.success) throw new Error(result.message || 'Erro ao criar');
return result.data;
},
onSuccess: () => {
olpToast.success('Criado com sucesso!');
queryClient.invalidateQueries({ queryKey: ['meu-modulo'] });
},
onError: (error: Error) => {
olpToast.error('Erro ao criar', { description: getUserFriendlyError(error) });
},
});
return { items, isLoading, criar: createMutation.mutateAsync };
}Anti-Padrões a Evitar
typescript
// ❌ Falta req: req no registrarLog
await registrarLog({
ip: extractIP(req),
acao: "...",
detalhes: { ... }
// MISSING: req: req
});
// ❌ Variável intermediária desnecessária
const clientIP = extractIP(req);
await registrarLog({ ip: clientIP, ... }); // falta req: req
// ❌ corsHeaders ausente na resposta de erro
return new Response(JSON.stringify({ success: false })); // sem headers!
// ❌ Auth check antes de ações públicas
user = await extractAuthenticatedUser(req); // bloquearia ações públicas
if (action === "list_public") { ... }
// ❌ String de mensagem inline
const msg = `Seu codigo: ${otp}. Valido por 5 min.`; // NÃO FAZER!
// ✅ CORRETO: ações públicas ANTES do auth check
if (action === "list_public") { ... }
user = await extractAuthenticatedUser(req);
// ✅ CORRETO: mensagem via helper centralizado
import { gerarMensagemSMS } from '../_shared/sms-templates.ts';
import { enviarMensagemComLog } from '../_shared/wasender-whatsapp.ts';
const msg = gerarMensagemSMS('otp_sistema', { otp });