Skip to content

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 = false

Nota: 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 });