Criou Edge Functions Etapa C

X-Lovable-Edit-ID: edt-f220de50-faff-44c3-87db-89969e0ea0b9
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-18 01:58:14 +00:00
commit ed4ab9745b
3 changed files with 296 additions and 0 deletions

View file

@ -38,3 +38,9 @@ verify_jwt = true
[functions.disconnect-integration]
verify_jwt = true
[functions.test-integration]
verify_jwt = true
[functions.parse-cronjob-natural-language]
verify_jwt = true

View file

@ -0,0 +1,194 @@
// Parser de descrições de cronjobs em linguagem natural.
// Usa Lovable AI Gateway (gemini-2.5-flash) para extrair cron + ação + MCPs,
// valida com cron-parser, gera human_readable em pt-BR via cronstrue.
import { corsHeaders } from "../_shared/cors.ts";
import { createClient } from "npm:@supabase/supabase-js@2";
// @ts-expect-error remote esm
import cronParser from "https://esm.sh/cron-parser@4.9.0";
// @ts-expect-error remote esm
import cronstrue from "https://esm.sh/cronstrue@2.50.0/i18n";
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY")!;
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const MODEL = "google/gemini-2.5-flash";
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const VALID_MCP_SLUGS = new Set([
"google_workspace",
"notion",
"todoist",
"calcom",
"microsoft_365",
]);
function buildSystemPrompt(tz: string): string {
return `Você é um parser de descrições de cronjobs em português para o assistente Mika. Receba uma descrição em linguagem natural e retorne APENAS JSON válido, sem markdown, sem explicações.
Regras:
- Timezone: ${tz}
- 'dia útil' = segunda a sexta (1-5)
- 'manhã' = 09:00, 'tarde' = 14:00, 'noite' = 19:00 ( se não especificado horário)
- 'primeiro dia do mês' = dia 1
- 'último dia do mês' = dia 28 (com warning sobre aproximação)
- Detecte MCPs mencionados (Gmail, Calendar, Drive, Notion, Todoist, Cal.com, Outlook, OneDrive) e mapeie para slugs: gmailgoogle_workspace, calendargoogle_workspace, drivegoogle_workspace, notionnotion, todoisttodoist, cal.comcalcom, outlookmicrosoft_365, onedrivemicrosoft_365
- Confidence: 'high' se claro, 'medium' se fez suposições razoáveis, 'low' se vago
Formato (APENAS este JSON):
{"cron_expression": "0 9 * * 1", "human_readable": "(gerado pelo sistema, não preencha)", "action_description": "enviar resumo da semana", "required_mcp_slugs": ["google_workspace"], "warnings": ["Interpretei 'manhã' como 09:00"], "confidence": "high"}`;
}
interface ParsedJob {
cron_expression: string;
action_description: string;
required_mcp_slugs: string[];
warnings: string[];
confidence: "high" | "medium" | "low";
}
function tryParseJson(text: string): ParsedJob | null {
// 1) direto
try {
return JSON.parse(text) as ParsedJob;
} catch (_) { /* segue */ }
// 2) extrai bloco { ... }
const match = text.match(/\{[\s\S]*\}/);
if (match) {
try {
return JSON.parse(match[0]) as ParsedJob;
} catch (_) { /* falhou */ }
}
return null;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
// Auth
const authHeader = req.headers.get("Authorization");
if (!authHeader) return json({ error: "Não autenticado" }, 401);
const token = authHeader.replace("Bearer ", "");
const { data: userData, error: userErr } = await admin.auth.getUser(token);
if (userErr || !userData?.user) return json({ error: "Não autenticado" }, 401);
let body: { natural_language_input?: string; user_timezone?: string };
try {
body = await req.json();
} catch {
return json({ error: "JSON inválido" }, 400);
}
const input = (body.natural_language_input ?? "").trim();
const tz = (body.user_timezone ?? "America/Sao_Paulo").trim() || "America/Sao_Paulo";
if (input.length < 5) {
return json({ error: "Descrição muito curta. Tente algo mais detalhado." }, 400);
}
if (input.length > 1000) {
return json({ error: "Descrição muito longa (máx 1000 caracteres)." }, 400);
}
// Chama Lovable AI Gateway
let aiRes: Response;
try {
aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
max_tokens: 500,
messages: [
{ role: "system", content: buildSystemPrompt(tz) },
{ role: "user", content: input },
],
}),
});
} catch (e) {
console.error("AI fetch error", e instanceof Error ? e.message : "unknown");
return json({ error: "Falha ao contatar IA. Tente novamente." }, 502);
}
if (aiRes.status === 429) {
return json({ error: "Muitas requisições. Aguarde 1 minuto." }, 429);
}
if (aiRes.status === 402) {
return json({ error: "Créditos de IA esgotados. Adicione créditos no workspace." }, 402);
}
if (!aiRes.ok) {
console.error("AI gateway error status", aiRes.status);
return json({ error: "Falha ao processar com IA." }, 502);
}
const aiData = await aiRes.json();
const content: string = aiData?.choices?.[0]?.message?.content ?? "";
const parsed = tryParseJson(content);
if (!parsed) {
return json(
{ error: "Não conseguimos entender a descrição. Tente reescrever de forma mais clara." },
422,
);
}
// Sanitiza required_mcp_slugs
const reqSlugs = Array.isArray(parsed.required_mcp_slugs)
? parsed.required_mcp_slugs.filter((s): s is string => typeof s === "string" && VALID_MCP_SLUGS.has(s))
: [];
const warnings = Array.isArray(parsed.warnings)
? parsed.warnings.filter((w): w is string => typeof w === "string")
: [];
const confidence: "high" | "medium" | "low" =
parsed.confidence === "high" || parsed.confidence === "medium" || parsed.confidence === "low"
? parsed.confidence
: "medium";
const cron = (parsed.cron_expression ?? "").trim();
if (!cron) {
return json({ error: "IA não retornou expressão cron. Tente reescrever." }, 422);
}
// Valida cron e calcula next_run_at
let nextRunAt: string | null = null;
try {
const interval = cronParser.parseExpression(cron, { tz });
nextRunAt = interval.next().toDate().toISOString();
} catch (e) {
return json(
{ error: `Expressão cron inválida: ${e instanceof Error ? e.message : "erro"}` },
422,
);
}
// Gera human_readable em pt-BR
let humanReadable = "";
try {
humanReadable = cronstrue.toString(cron, { locale: "pt_BR" });
} catch (_) {
humanReadable = cron; // fallback
}
const action = (parsed.action_description ?? "").trim();
return json({
cron_expression: cron,
human_readable: humanReadable,
action_description: action,
required_mcp_slugs: reqSlugs,
warnings,
confidence,
next_run_at: nextRunAt,
});
});

View file

@ -0,0 +1,96 @@
// Testa uma integração OAuth fazendo uma chamada leve ao provider.
// Não consome refresh token. Atualiza status caso receba 401.
import { createClient } from "npm:@supabase/supabase-js@2";
import { corsHeaders } from "../_shared/cors.ts";
import { type ProviderSlug, testProviderConnection } from "../_shared/oauth-providers.ts";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const authHeader = req.headers.get("Authorization");
if (!authHeader) return json({ error: "Não autenticado" }, 401);
const token = authHeader.replace("Bearer ", "");
const { data: userData, error: userErr } = await admin.auth.getUser(token);
if (userErr || !userData?.user) return json({ error: "Não autenticado" }, 401);
const userId = userData.user.id;
let body: { integration_id?: string };
try {
body = await req.json();
} catch {
return json({ error: "JSON inválido" }, 400);
}
const { integration_id } = body;
if (!integration_id) return json({ error: "integration_id é obrigatório" }, 400);
// Busca integração + ownership + mcp slug
const { data: integration, error: intErr } = await admin
.from("user_integrations")
.select(
"id, user_id, status, access_token_vault_id, mcp_id, available_mcps!inner(slug, supports_refresh_token)",
)
.eq("id", integration_id)
.maybeSingle();
if (intErr || !integration) return json({ error: "Integração não encontrada" }, 404);
if (integration.user_id !== userId) return json({ error: "Acesso negado" }, 403);
if (!integration.access_token_vault_id) return json({ error: "Token ausente" }, 400);
// @ts-expect-error nested
const slug = integration.available_mcps.slug as ProviderSlug;
// @ts-expect-error nested
const supportsRefresh = integration.available_mcps.supports_refresh_token as boolean;
// Decrypt access token
const { data: secretRows, error: secretErr } = await admin.rpc("vault_decrypt_secret", {
secret_id: integration.access_token_vault_id,
});
if (secretErr || !secretRows?.[0]?.decrypted_secret) {
return json({ error: "Falha ao recuperar token" }, 500);
}
const accessToken = secretRows[0].decrypted_secret as string;
try {
const result = await testProviderConnection(slug, accessToken);
if (result.ok) {
// Garante status 'active' se estava em erro/expirado
if (integration.status !== "active") {
await admin
.from("user_integrations")
.update({ status: "active", error_message: null, updated_at: new Date().toISOString() })
.eq("id", integration_id);
}
return json({ success: true, account_info: result.account ?? null });
}
if (result.status === 401) {
const newStatus = supportsRefresh ? "expired" : "revoked";
await admin
.from("user_integrations")
.update({
status: newStatus,
error_message: "Token inválido. Reconecte a integração.",
updated_at: new Date().toISOString(),
})
.eq("id", integration_id);
return json({ error: "Token inválido", status: newStatus }, 401);
}
return json({ error: `Provider retornou ${result.status}` }, 502);
} catch (e) {
console.error("test-integration error", e instanceof Error ? e.message : "unknown");
return json({ error: "Falha ao testar conexão" }, 500);
}
});