From 19004d0cc5d5d2cb41de3f0491c7dc8f1edf3f81 Mon Sep 17 00:00:00 2001 From: Marcio Bevervanso Date: Tue, 16 Jun 2026 15:43:45 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20automa=C3=A7=C3=A3o=20aut=C3=B4noma=20d?= =?UTF-8?q?o=20engine=20(n8n)=20=E2=80=94=20m=C3=A9tricas=20de=20compra,?= =?UTF-8?q?=20persist=C3=AAncia=20e=20alertas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Torna o engine de automação realmente autônomo e seguro: - métricas: lê COMPRA de verdade (actions/purchase_roas) e calcula CPA; novas métricas 'cpa' e 'purchases'; regras padrão afinadas p/ low-ticket - orçamento: ações de budget agora agem no CONJUNTO (não na campanha, que lançava erro); usa listAdSets + updateAdSetBudget - segurança: "substituir via IA" desativado (criava campanha com page_id/ link inválidos) — agora só pausa e recomenda - modo de autonomia 'mixed': pausar/notificar executa; orçamento/IA viram só recomendação (com cooldown que evita re-notificar) - persistência (Supabase): regras, logs e token (app_config) — store.ts com fallback gracioso; SQL em docs/automation.sql - endpoint /api/engine/cron p/ o n8n acionar (CRON_SECRET), avalia, grava logs e dispara webhook (N8N_WEBHOOK_URL); modo summary p/ resumo diário - token de login persistido no callback p/ rodar sem cookie - aba Atividade agora lê logs reais via /api/engine/logs Co-Authored-By: Claude Opus 4.8 --- docs/automation.sql | 54 +++++++ src/app/api/auth/callback/route.ts | 9 ++ src/app/api/engine/cron/route.ts | 156 ++++++++++++++++++++ src/app/api/engine/logs/route.ts | 22 +++ src/app/api/engine/rules/route.ts | 34 ++--- src/app/dashboard/activity/page.tsx | 67 ++++----- src/lib/engine/monitor.ts | 213 +++++++++++++++------------- src/lib/engine/rules.ts | 62 ++++---- src/lib/engine/store.ts | 137 ++++++++++++++++++ 9 files changed, 565 insertions(+), 189 deletions(-) create mode 100644 docs/automation.sql create mode 100644 src/app/api/engine/cron/route.ts create mode 100644 src/app/api/engine/logs/route.ts create mode 100644 src/lib/engine/store.ts diff --git a/docs/automation.sql b/docs/automation.sql new file mode 100644 index 0000000..9f9d4c6 --- /dev/null +++ b/docs/automation.sql @@ -0,0 +1,54 @@ +-- ============================================================ +-- Tabelas da Automação (rode no Supabase → SQL Editor) +-- ============================================================ +-- O app acessa o Supabase com a chave anon (server-side). Mantemos RLS +-- desabilitado nestas tabelas para o engine ler/escrever, igual ao restante +-- do app (tabela agent_campaigns). +-- +-- ⚠️ SEGURANÇA: app_config guarda o token da Meta. Com RLS off + chave anon, +-- quem tiver a anon key consegue ler. Para uma ferramenta privada de um único +-- dono está ok; para travar de vez, prefira definir META_SYSTEM_TOKEN por env +-- (token de System User) e NÃO depender do token salvo aqui. + +-- Regras de automação ---------------------------------------- +create table if not exists automation_rules ( + id uuid primary key default gen_random_uuid(), + name text not null, + enabled boolean not null default true, + metric text not null, + operator text not null, + threshold numeric not null, + min_spend numeric not null default 0, + min_impressions integer not null default 0, + action text not null, + action_value numeric, + cooldown_hours integer not null default 24, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Logs de atividade do engine -------------------------------- +create table if not exists automation_logs ( + id uuid primary key default gen_random_uuid(), + rule_id text, + rule_name text, + campaign_id text not null, + campaign_name text, + action text, + details jsonb, + status text, + created_at timestamptz not null default now() +); +create index if not exists automation_logs_created_idx on automation_logs (created_at desc); + +-- Config chave/valor (token da Meta, etc.) ------------------- +create table if not exists app_config ( + key text primary key, + value text, + updated_at timestamptz not null default now() +); + +-- Mantém o acesso via chave anon (igual ao resto do app). +alter table automation_rules disable row level security; +alter table automation_logs disable row level security; +alter table app_config disable row level security; diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index fcf853b..d886bc3 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -97,6 +97,15 @@ export async function GET(request: NextRequest) { picture: meData.picture?.data?.url || '', }); + // Persiste o token pro engine de automação rodar sem cookie (acionado pelo + // n8n). Best-effort: se o Supabase estiver fora, não bloqueia o login. + try { + const { setConfig } = await import('@/lib/engine/store'); + await setConfig('meta_token', accessToken); + } catch { + // ignora — automação volta a ter token no próximo login + } + // 5. Redirect to dashboard return NextResponse.redirect(new URL('/dashboard', baseUrl)); } catch (err) { diff --git a/src/app/api/engine/cron/route.ts b/src/app/api/engine/cron/route.ts new file mode 100644 index 0000000..ccd2dc4 --- /dev/null +++ b/src/app/api/engine/cron/route.ts @@ -0,0 +1,156 @@ +// ============================================ +// Engine — Endpoint autônomo (acionado pelo n8n) +// ============================================ +// O n8n agenda e bate aqui (POST). Protegido por CRON_SECRET. +// - default: avalia campanhas e age conforme o modo (auto|notify|mixed) +// - { "summary": true }: envia um resumo (gasto/compras/CPA) sem agir +// Notifica o n8n de volta via N8N_WEBHOOK_URL. + +import { NextRequest, NextResponse } from 'next/server'; +import { createMetaMCPClient } from '@/lib/mcp/client'; +import { getAccountInsights } from '@/lib/mcp/tools'; +import { evaluateCampaigns, type AutonomyMode } from '@/lib/engine/monitor'; +import { loadRules, loadRecentLogs, saveLogs, getConfig } from '@/lib/engine/store'; +import type { ActivityLog } from '@/lib/engine/rules'; + +const PURCHASE_ACTION_TYPES = [ + 'purchase', + 'omni_purchase', + 'onsite_conversion.purchase', + 'offsite_conversion.fb_pixel_purchase', +]; + +function sumActions(actions: Array<{ action_type?: string; value?: string }> | undefined, types: string[]): number { + if (!Array.isArray(actions)) return 0; + return actions.reduce((t, a) => (a.action_type && types.includes(a.action_type) ? t + parseFloat(a.value || '0') : t), 0); +} + +async function notifyN8n(payload: Record): Promise { + const url = process.env.N8N_WEBHOOK_URL; + if (!url) return; + try { + await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(8000), + }); + } catch (e) { + console.warn('[Engine.cron] webhook n8n falhou:', e instanceof Error ? e.message : e); + } +} + +export async function POST(request: NextRequest) { + // Auth via CRON_SECRET (ignora se não estiver configurado/placeholder). + const cronSecret = process.env.CRON_SECRET; + if (cronSecret && cronSecret !== 'your_random_cron_secret') { + if (request.headers.get('authorization') !== `Bearer ${cronSecret}`) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }); + } + } + + const body = await request.json().catch(() => ({} as Record)); + const token = (body.access_token as string) || (await getConfig('meta_token')) || process.env.META_SYSTEM_TOKEN; + + if (!token) { + return NextResponse.json( + { success: false, error: 'Sem token da Meta. Faça login no app (persiste o token) ou defina META_SYSTEM_TOKEN.' }, + { status: 401 } + ); + } + + const mode = ((body.mode as AutonomyMode) || (process.env.AUTOMATION_MODE as AutonomyMode) || 'mixed'); + + let client: Awaited> | undefined; + try { + client = await createMetaMCPClient(token); + + // ---- Modo resumo: só relatório, não age ---- + if (body.summary === true) { + const ins = (await getAccountInsights(client, 'today')) as Record; + const spend = parseFloat((ins?.spend as string) || '0'); + const purchases = sumActions(ins?.actions as never, PURCHASE_ACTION_TYPES); + const roasArr = ins?.purchase_roas as Array<{ value?: string }> | undefined; + const roas = roasArr?.[0]?.value ? parseFloat(roasArr[0].value) : 0; + const cpa = purchases > 0 ? spend / purchases : 0; + + const summary = { + type: 'daily_summary', + ran_at: new Date().toISOString(), + spend: Number(spend.toFixed(2)), + purchases, + cpa: Number(cpa.toFixed(2)), + roas: Number(roas.toFixed(2)), + ctr: parseFloat((ins?.ctr as string) || '0'), + cpm: parseFloat((ins?.cpm as string) || '0'), + }; + + await notifyN8n(summary); + return NextResponse.json({ success: true, data: summary }); + } + + // ---- Modo avaliação: aplica regras ---- + const [rules, recentLogs] = await Promise.all([loadRules(), loadRecentLogs(72)]); + const evaluations = await evaluateCampaigns(client, rules, recentLogs, mode); + const triggered = evaluations.filter((e) => e.triggered); + + const logs: ActivityLog[] = triggered.map((e) => ({ + rule_id: e.rule.id ?? null, + rule_name: e.rule.name, + campaign_id: e.campaign_id, + campaign_name: e.campaign_name, + action: e.action_taken || e.recommended_action || 'notify', + details: { + metric: e.rule.metric, + value: e.metric_value, + threshold: e.rule.threshold, + text: e.details, + }, + status: e.action_taken ? 'executed' : e.recommended_action ? 'pending' : 'skipped', + created_at: e.timestamp, + })); + + await saveLogs(logs); + + if (triggered.length > 0) { + await notifyN8n({ + type: 'automation_run', + ran_at: new Date().toISOString(), + mode, + actions: triggered.map((e) => ({ + campaign: e.campaign_name, + rule: e.rule.name, + executed: !!e.action_taken, + action: e.action_taken || e.recommended_action, + details: e.details, + })), + }); + } + + console.log(`[Engine.cron] modo=${mode} avaliadas=${evaluations.length} disparadas=${triggered.length}`); + + return NextResponse.json({ + success: true, + data: { + ran_at: new Date().toISOString(), + mode, + total_triggered: triggered.length, + actions: triggered.map((e) => ({ + campaign: e.campaign_name, + rule: e.rule.name, + executed: !!e.action_taken, + action: e.action_taken || e.recommended_action, + details: e.details, + })), + }, + }); + } catch (error) { + console.error('[Engine.cron] erro:', error); + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Engine error' }, + { status: 500 } + ); + } finally { + if (client) await client.close(); + } +} diff --git a/src/app/api/engine/logs/route.ts b/src/app/api/engine/logs/route.ts new file mode 100644 index 0000000..0e8a9b3 --- /dev/null +++ b/src/app/api/engine/logs/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import { loadRecentLogs } from '@/lib/engine/store'; + +// Logs de atividade do engine (últimos dias), pra aba Atividade. +export async function GET() { + const logs = await loadRecentLogs(24 * 14); // últimas 2 semanas + + const data = logs.map((log, i) => { + const details = (log.details || {}) as Record; + return { + id: (log.id as string) || `log-${i}`, + timestamp: log.created_at || new Date().toISOString(), + campaign_name: log.campaign_name, + rule_name: log.rule_name || '—', + action: log.action, + details: (details.text as string) || JSON.stringify(details), + status: log.status, + }; + }); + + return NextResponse.json({ success: true, data }); +} diff --git a/src/app/api/engine/rules/route.ts b/src/app/api/engine/rules/route.ts index 0d328f5..e6b0bc6 100644 --- a/src/app/api/engine/rules/route.ts +++ b/src/app/api/engine/rules/route.ts @@ -1,30 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; -import { DEFAULT_RULES } from '@/lib/engine/rules'; -import type { AutomationRule } from '@/lib/engine/rules'; - -// In-memory store (replace with Supabase when configured) -let rules: AutomationRule[] = DEFAULT_RULES.map((r, i) => ({ - ...r, - id: `rule-${i + 1}`, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), -})); +import { loadRules, createRule, updateRule, deleteRule } from '@/lib/engine/store'; export async function GET() { + const rules = await loadRules(); return NextResponse.json({ success: true, data: rules }); } export async function POST(request: NextRequest) { try { const body = await request.json(); - const newRule: AutomationRule = { - ...body, - id: `rule-${Date.now()}`, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - rules.push(newRule); - return NextResponse.json({ success: true, data: newRule }, { status: 201 }); + const rule = await createRule(body); + return NextResponse.json({ success: true, data: rule }, { status: 201 }); } catch (error) { return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Invalid data' }, @@ -37,12 +23,11 @@ export async function PATCH(request: NextRequest) { try { const body = await request.json(); const { id, ...updates } = body; - const index = rules.findIndex((r) => r.id === id); - if (index === -1) { + const updated = await updateRule(id, updates); + if (!updated) { return NextResponse.json({ success: false, error: 'Rule not found' }, { status: 404 }); } - rules[index] = { ...rules[index], ...updates, updated_at: new Date().toISOString() }; - return NextResponse.json({ success: true, data: rules[index] }); + return NextResponse.json({ success: true, data: updated }); } catch (error) { return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Invalid data' }, @@ -53,10 +38,9 @@ export async function PATCH(request: NextRequest) { export async function DELETE(request: NextRequest) { try { - const { searchParams } = request.nextUrl; - const id = searchParams.get('id'); + const id = request.nextUrl.searchParams.get('id'); if (!id) return NextResponse.json({ success: false, error: 'ID required' }, { status: 400 }); - rules = rules.filter((r) => r.id !== id); + await deleteRule(id); return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx index 5da735f..cf43c74 100644 --- a/src/app/dashboard/activity/page.tsx +++ b/src/app/dashboard/activity/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Activity, CheckCircle, Clock, XCircle, Zap, Filter } from 'lucide-react'; +import { Spinner } from '@/components/ui/Spinner'; interface LogEntry { id: string; @@ -13,35 +14,6 @@ interface LogEntry { status: 'executed' | 'pending' | 'failed' | 'skipped'; } -// Demo data (will be replaced with Supabase query) -const DEMO_LOGS: LogEntry[] = [ - { - id: '1', timestamp: new Date(Date.now() - 3600000).toISOString(), - campaign_name: 'Campanha Conversão Q1', rule_name: 'CPL Alto — Pausar', - action: 'pause', details: 'CPL = R$67.30 (threshold: R$50.00)', status: 'executed', - }, - { - id: '2', timestamp: new Date(Date.now() - 7200000).toISOString(), - campaign_name: 'Remarketing Visitantes', rule_name: 'ROAS Baixo — Pausar', - action: 'pause', details: 'ROAS = 0.32x (threshold: 0.50x)', status: 'executed', - }, - { - id: '3', timestamp: new Date(Date.now() - 14400000).toISOString(), - campaign_name: 'Tráfego Blog', rule_name: 'CTR Muito Baixo — Pausar', - action: 'pause', details: 'CTR = 0.21% (threshold: 0.50%)', status: 'executed', - }, - { - id: '4', timestamp: new Date(Date.now() - 28800000).toISOString(), - campaign_name: 'Leads Premium', rule_name: 'ROAS Excelente — Aumentar Budget', - action: 'increase_budget', details: 'Budget +20%: R$50.00 → R$60.00. ROAS = 4.2x', status: 'executed', - }, - { - id: '5', timestamp: new Date(Date.now() - 43200000).toISOString(), - campaign_name: 'Campanha Vídeo', rule_name: 'CPC Alto — Reduzir Budget', - action: 'reduce_budget', details: 'Em cooldown (48h)', status: 'skipped', - }, -]; - const statusConfig = { executed: { icon: , color: '#4ade80', bg: 'rgba(34,197,94,0.1)', label: 'Executado' }, pending: { icon: , color: '#fbbf24', bg: 'rgba(245,158,11,0.1)', label: 'Pendente' }, @@ -61,7 +33,24 @@ function timeAgo(dateStr: string): string { export default function ActivityPage() { const [filter, setFilter] = useState('all'); - const logs = filter === 'all' ? DEMO_LOGS : DEMO_LOGS.filter((l) => l.status === filter); + const [allLogs, setAllLogs] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + try { + const res = await fetch('/api/engine/logs'); + const data = await res.json(); + if (data.success) setAllLogs(data.data || []); + } catch { + /* ignora */ + } finally { + setLoading(false); + } + })(); + }, []); + + const logs = filter === 'all' ? allLogs : allLogs.filter((l) => l.status === filter); return (
@@ -90,10 +79,10 @@ export default function ActivityPage() { {/* Stats */}
{[ - { label: 'Total', value: DEMO_LOGS.length, color: 'var(--accent-soft-fg)' }, - { label: 'Executados', value: DEMO_LOGS.filter((l) => l.status === 'executed').length, color: '#4ade80' }, - { label: 'Pendentes', value: DEMO_LOGS.filter((l) => l.status === 'pending').length, color: '#fbbf24' }, - { label: 'Pulados', value: DEMO_LOGS.filter((l) => l.status === 'skipped').length, color: '#8888a0' }, + { label: 'Total', value: allLogs.length, color: 'var(--accent-soft-fg)' }, + { label: 'Executados', value: allLogs.filter((l) => l.status === 'executed').length, color: '#4ade80' }, + { label: 'Pendentes', value: allLogs.filter((l) => l.status === 'pending').length, color: '#fbbf24' }, + { label: 'Pulados', value: allLogs.filter((l) => l.status === 'skipped').length, color: '#8888a0' }, ].map((s) => (

{s.value}

@@ -108,9 +97,13 @@ export default function ActivityPage() {

Timeline

- {logs.length === 0 ? ( + {loading ? ( +
+ +
+ ) : logs.length === 0 ? (
-

Nenhuma atividade encontrada

+

Nenhuma atividade ainda. Quando o engine agir ou recomendar algo, aparece aqui.

) : ( logs.map((log) => { diff --git a/src/lib/engine/monitor.ts b/src/lib/engine/monitor.ts index 81659ab..fee1b49 100644 --- a/src/lib/engine/monitor.ts +++ b/src/lib/engine/monitor.ts @@ -3,30 +3,65 @@ // ============================================ import type { Client } from '@/lib/mcp/client'; -import { listCampaigns, getCampaignInsights, pauseCampaign, updateCampaignBudget } from '@/lib/mcp/tools'; +import { listCampaigns, getCampaignInsights, pauseCampaign, listAdSets, updateAdSetBudget } from '@/lib/mcp/tools'; +import { AUTO_EXECUTE_ACTIONS } from './rules'; import type { AutomationRule, RuleEvaluation, ActivityLog } from './rules'; +export type AutonomyMode = 'auto' | 'notify' | 'mixed'; + +// Tipos de ação (na Meta) que contam como compra e como lead. +const PURCHASE_ACTION_TYPES = [ + 'purchase', + 'omni_purchase', + 'onsite_conversion.purchase', + 'offsite_conversion.fb_pixel_purchase', +]; +const LEAD_ACTION_TYPES = ['lead', 'onsite_conversion.lead_grouped']; + +// Soma os valores de um conjunto de action_types dentro de insights.actions. +function sumActions(insights: Record | null, types: string[]): number { + const actions = (insights?.actions as Array<{ action_type?: string; value?: string }>) || []; + let total = 0; + for (const a of actions) { + if (a.action_type && types.includes(a.action_type)) { + total += parseFloat(a.value || '0'); + } + } + return total; +} + +function getPurchases(insights: Record | null): number { + return sumActions(insights, PURCHASE_ACTION_TYPES); +} + // ---- Extract metric value from MCP insights ---- function getMetricValue(insights: Record | null, metric: string): number { if (!insights) return 0; + const spend = parseFloat((insights.spend as string) || '0'); switch (metric) { + case 'cpa': { + const purchases = getPurchases(insights); + return purchases > 0 ? spend / purchases : 0; // 0 = sem compra (use a regra 'purchases') + } + case 'purchases': + return getPurchases(insights); case 'cpl': { - const spend = parseFloat((insights.spend as string) || '0'); - const leads = parseInt((insights.leads as string) || '0', 10); - if (leads === 0) return 0; - return spend / leads; + const leads = sumActions(insights, LEAD_ACTION_TYPES); + return leads > 0 ? spend / leads : 0; } case 'cpc': return parseFloat((insights.cpc as string) || '0'); case 'ctr': return parseFloat((insights.ctr as string) || '0'); - case 'roas': - return parseFloat((insights.roas as string) || '0'); + case 'roas': { + const roasArr = insights.purchase_roas as Array<{ value?: string }> | undefined; + return roasArr?.[0]?.value ? parseFloat(roasArr[0].value) : 0; + } case 'cpm': return parseFloat((insights.cpm as string) || '0'); case 'spend': - return parseFloat((insights.spend as string) || '0'); + return spend; default: return 0; } @@ -63,16 +98,41 @@ function isInCooldown( (log) => log.campaign_id === campaignId && log.rule_id === ruleId && - log.status === 'executed' && + // Executada OU já recomendada (pending) conta — evita re-agir e re-notificar. + (log.status === 'executed' || log.status === 'pending') && new Date(log.created_at || '') > cutoff ); } +// Ajusta o orçamento de TODOS os conjuntos da campanha (o orçamento fica no +// conjunto, não na campanha). factor 0.7 = -30%, 1.2 = +20%. Piso de R$1,00. +async function adjustAdSetBudgets(client: Client, campaignId: string, factor: number): Promise { + const adSets = (await listAdSets(client, campaignId)) as Array<{ + id: string; + name?: string; + daily_budget?: string; + }> | null; + + if (!Array.isArray(adSets) || adSets.length === 0) { + return 'nenhum conjunto encontrado'; + } + + const changes: string[] = []; + for (const adSet of adSets) { + const current = parseInt(adSet.daily_budget || '0', 10); + if (!current) continue; // conjunto sem orçamento próprio (campanha CBO) + const next = Math.max(100, Math.round(current * factor)); + await updateAdSetBudget(client, adSet.id, next); + changes.push(`${adSet.name || adSet.id}: R$${(current / 100).toFixed(2)}→R$${(next / 100).toFixed(2)}`); + } + + return changes.length ? changes.join(' · ') : 'nenhum conjunto com orçamento próprio (CBO?)'; +} + // ---- Execute action on campaign via MCP ---- async function executeAction( mcpClient: Client, campaignId: string, - dailyBudget: number, insights: Record, rule: AutomationRule ): Promise<{ success: boolean; details: string }> { @@ -87,92 +147,29 @@ async function executeAction( } case 'reduce_budget': { const reduction = rule.action_value || 30; - const newBudget = Math.round(dailyBudget * (1 - reduction / 100)); - if (newBudget < 100) return { success: false, details: 'Budget já no mínimo (R$1.00)' }; - await updateCampaignBudget(mcpClient, campaignId, newBudget); - return { - success: true, - details: `Budget reduzido ${reduction}%: R$${(dailyBudget / 100).toFixed(2)} → R$${(newBudget / 100).toFixed(2)}`, - }; + const details = await adjustAdSetBudgets(mcpClient, campaignId, 1 - reduction / 100); + return { success: true, details: `Budget reduzido ${reduction}% → ${details}` }; } case 'increase_budget': { const increase = rule.action_value || 20; - const newBgt = Math.round(dailyBudget * (1 + increase / 100)); - await updateCampaignBudget(mcpClient, campaignId, newBgt); - return { - success: true, - details: `Budget aumentado ${increase}%: R$${(dailyBudget / 100).toFixed(2)} → R$${(newBgt / 100).toFixed(2)}`, - }; + const details = await adjustAdSetBudgets(mcpClient, campaignId, 1 + increase / 100); + return { success: true, details: `Budget aumentado ${increase}% → ${details}` }; } case 'notify': { return { success: true, - details: `Alerta: ${rule.metric.toUpperCase()} = ${getMetricValue(insights, rule.metric).toFixed(2)} (threshold: ${rule.threshold})`, + details: `Alerta: ${rule.metric.toUpperCase()} = ${getMetricValue(insights, rule.metric).toFixed(2)} (limite: ${rule.threshold})`, }; } case 'replace_with_ai': { - // 1. Pausar a campanha atual + // Substituição automática de criativo desativada por segurança (a versão + // anterior criava campanha com page_id/link fixos e inválidos). Aqui só + // pausamos e recomendamos recriar o criativo manualmente no app. await pauseCampaign(mcpClient, campaignId); - - // 2. Acionar a IA para gerar novos criativos - try { - // Gerar Copy via OpenAI (gpt-5.4-mini) - const { getAIClient } = await import('@/lib/ai/client'); - const openai = getAIClient(); - - const copyPrompt = `A campanha "${insights.campaign_name || campaignId}" performou mal (CPL alto / ROAS baixo). - Você é um expert em Meta Ads. Crie uma nova Headline curta (max 40 chars) e um Primary Text (max 125 chars) muito persuasivo para substituí-la. - Retorne em JSON: {"headline": "...", "primary_text": "..."}`; - - const copyResult = await openai.chat.completions.create({ - model: 'gpt-5.4-mini', - messages: [{ role: 'user', content: copyPrompt }], - response_format: { type: 'json_object' }, - }); - const copy = JSON.parse(copyResult.choices[0]?.message?.content || '{}'); - - // Gerar Imagem via OpenAI (gpt-image-2) - const imageRes = await openai.images.generate({ - model: 'gpt-image-2', - prompt: `Uma imagem profissional, chamativa e de alta conversão para um anúncio no Facebook/Instagram sobre: ${copy.headline || 'Produto Inovador'}. Estilo moderno, sem texto na imagem.`, - n: 1, - size: '1024x1024' - }); - const image = imageRes.data?.[0]; - const imageUrl = image?.url || (image?.b64_json ? `data:image/png;base64,${image.b64_json}` : undefined); - - if (!imageUrl) throw new Error('Falha ao gerar imagem'); - - // 3. Montar e Publicar a Nova Campanha via MCP - const { createFullCampaign } = await import('@/lib/mcp/tools'); - - const newCampaign = await createFullCampaign(mcpClient, { - campaign_name: `[AI Gen] Substituto de ${insights.campaign_name || campaignId}`, - objective: 'OUTCOME_LEADS', // default safe objective - daily_budget: dailyBudget > 0 ? dailyBudget : 1000, - bid_strategy: 'LOWEST_COST_WITH_BID_CAP', - bid_amount: 5000, // Fixed 50 BRL bid cap for safety - targeting: { geo_locations: { countries: ['BR'] } }, - page_id: '123456789', // requires a valid page_id in production - image_urls: [imageUrl], - link: 'https://seusite.com.br', - headline: copy.headline || 'Nova Oferta Irresistível', - primary_text: copy.primary_text || 'Descubra como transformar seus resultados hoje mesmo.', - description: 'Clique e saiba mais', - cta: 'LEARN_MORE', - }); - - return { - success: true, - details: `Campanha pausada. Nova campanha [AI Gen] criada com sucesso! (ID: ${newCampaign.campaign_id})`, - }; - - } catch (aiError: any) { - return { - success: false, - details: `Campanha pausada, mas falha ao criar IA: ${aiError.message}`, - }; - } + return { + success: true, + details: 'Campanha pausada. Recomendado: recriar o criativo no app (substituição via IA automática desativada por segurança).', + }; } default: return { success: false, details: 'Ação desconhecida' }; @@ -186,10 +183,21 @@ async function executeAction( } // ---- Main evaluation function ---- +// Decide se a ação é executada de fato ou vira só notificação, conforme o modo: +// - 'auto' → executa qualquer ação. +// - 'notify' → nunca executa, só recomenda. +// - 'mixed' → executa só pausar/notificar; orçamento e IA viram recomendação. +function shouldExecute(mode: AutonomyMode, action: AutomationRule['action']): boolean { + if (mode === 'auto') return true; + if (mode === 'notify') return false; + return AUTO_EXECUTE_ACTIONS.includes(action); +} + export async function evaluateCampaigns( mcpClient: Client, rules: AutomationRule[], - recentLogs: ActivityLog[] = [] + recentLogs: ActivityLog[] = [], + mode: AutonomyMode = 'mixed' ): Promise { const enabledRules = rules.filter((r) => r.enabled); if (enabledRules.length === 0) return []; @@ -204,7 +212,6 @@ export async function evaluateCampaigns( for (const campaign of activeCampaigns) { const campaignId = campaign.id as string; const campaignName = campaign.name as string; - const dailyBudget = parseInt((campaign.daily_budget as string) || '0', 10); let insights: Record | null = null; try { @@ -219,23 +226,14 @@ export async function evaluateCampaigns( if (!meetsMinimums(insights, rule)) continue; if (isInCooldown(campaignId, rule.id, recentLogs, rule.cooldown_hours)) { - evaluations.push({ - rule, - campaign_id: campaignId, - campaign_name: campaignName, - metric_value: metricValue, - triggered: false, - action_taken: null, - details: `Em cooldown (${rule.cooldown_hours}h)`, - timestamp: new Date().toISOString(), - }); - continue; + continue; // já agiu recentemente — silencioso pra não poluir o log } - const triggered = evaluateCondition(metricValue, rule.operator, rule.threshold); + if (!evaluateCondition(metricValue, rule.operator, rule.threshold)) continue; - if (triggered) { - const result = await executeAction(mcpClient, campaignId, dailyBudget, insights!, rule); + // Regra disparou. Executa ou só recomenda, conforme o modo. + if (shouldExecute(mode, rule.action)) { + const result = await executeAction(mcpClient, campaignId, insights!, rule); evaluations.push({ rule, campaign_id: campaignId, @@ -243,9 +241,22 @@ export async function evaluateCampaigns( metric_value: metricValue, triggered: true, action_taken: result.success ? rule.action : null, + recommended_action: rule.action, details: result.details, timestamp: new Date().toISOString(), }); + } else { + evaluations.push({ + rule, + campaign_id: campaignId, + campaign_name: campaignName, + metric_value: metricValue, + triggered: true, + action_taken: null, + recommended_action: rule.action, + details: `Recomendação (modo aviso): ${rule.action} — ${rule.metric.toUpperCase()} = ${metricValue.toFixed(2)} (limite: ${rule.threshold})`, + timestamp: new Date().toISOString(), + }); } } } diff --git a/src/lib/engine/rules.ts b/src/lib/engine/rules.ts index 82dcc86..37421ec 100644 --- a/src/lib/engine/rules.ts +++ b/src/lib/engine/rules.ts @@ -2,7 +2,7 @@ // Automation Engine — Rule Types & Defaults // ============================================ -export type RuleMetric = 'cpl' | 'cpc' | 'ctr' | 'roas' | 'cpm' | 'spend'; +export type RuleMetric = 'cpa' | 'purchases' | 'cpl' | 'cpc' | 'ctr' | 'roas' | 'cpm' | 'spend'; export type RuleOperator = '>' | '<' | '>=' | '<='; export type RuleAction = 'pause' | 'reduce_budget' | 'increase_budget' | 'notify' | 'replace_with_ai'; @@ -29,6 +29,8 @@ export interface RuleEvaluation { metric_value: number; triggered: boolean; action_taken: RuleAction | null; + // Ação que a regra recomendou (mesmo quando só notificou, sem executar). + recommended_action?: RuleAction | null; details: string; timestamp: string; } @@ -47,6 +49,8 @@ export interface ActivityLog { // ---- Metric Labels (PT-BR) ---- export const METRIC_LABELS: Record = { + cpa: 'Custo por Compra (CPA)', + purchases: 'Nº de Compras', cpl: 'Custo por Lead (CPL)', cpc: 'Custo por Clique (CPC)', ctr: 'Taxa de Clique (CTR %)', @@ -70,27 +74,29 @@ export const ACTION_LABELS: Record = { replace_with_ai: 'Pausar e Substituir via IA', }; -// ---- Default Rules ---- +// ---- Default Rules (afinadas para produto de entrada / otimização por compra) ---- +// Ajuste os thresholds ao seu funil: o CPA aceitável é função do quanto cada +// comprador rende (entrada + order bump + upsell), não dos R$9,99. export const DEFAULT_RULES: Omit[] = [ { - name: 'CPL Alto — Pausar', + name: 'Gastou e não vendeu — Pausar', enabled: true, - metric: 'cpl', - operator: '>', - threshold: 50, - min_spend: 30, - min_impressions: 1000, + metric: 'purchases', + operator: '<', + threshold: 1, // 0 compras + min_spend: 40, // já gastou o suficiente pra concluir que não converte + min_impressions: 1500, action: 'pause', cooldown_hours: 24, }, { - name: 'ROAS Baixo — Pausar', + name: 'CPA Alto — Pausar', enabled: true, - metric: 'roas', - operator: '<', - threshold: 0.5, - min_spend: 50, - min_impressions: 2000, + metric: 'cpa', + operator: '>', + threshold: 30, // custo por compra acima do seu lucro por comprador + min_spend: 40, + min_impressions: 1500, action: 'pause', cooldown_hours: 24, }, @@ -99,30 +105,30 @@ export const DEFAULT_RULES: Omit', - threshold: 5, - min_spend: 30, - min_impressions: 1000, + name: 'ROAS Baixo — Reduzir Budget 30%', + enabled: false, // em modo misto, vira só aviso + metric: 'roas', + operator: '<', + threshold: 0.8, + min_spend: 60, + min_impressions: 3000, action: 'reduce_budget', action_value: 30, cooldown_hours: 48, }, { name: 'ROAS Excelente — Aumentar Budget 20%', - enabled: false, + enabled: false, // em modo misto, vira só aviso metric: 'roas', operator: '>', - threshold: 3, + threshold: 2.5, min_spend: 100, min_impressions: 5000, action: 'increase_budget', @@ -130,3 +136,7 @@ export const DEFAULT_RULES: Omit ({ ...r, id: `default-${i}` })); +} + +// ---- Regras ---- +export async function loadRules(): Promise { + try { + const supabase = getSupabaseServerClient(); + const { data, error } = await supabase + .from(RULES_TABLE) + .select('*') + .order('created_at', { ascending: true }); + if (error) throw error; + + if (Array.isArray(data) && data.length > 0) { + return data as unknown as AutomationRule[]; + } + + // Tabela vazia → semeia com as regras padrão. + const { data: inserted } = await supabase + .from(RULES_TABLE) + .insert(DEFAULT_RULES as never) + .select(); + return (inserted as unknown as AutomationRule[]) || defaultsWithIds(); + } catch (e) { + console.warn('[Engine.store] loadRules fallback:', e instanceof Error ? e.message : e); + return defaultsWithIds(); + } +} + +export async function createRule( + rule: Omit +): Promise { + const supabase = getSupabaseServerClient(); + const { data, error } = await supabase + .from(RULES_TABLE) + .insert(rule as never) + .select() + .single(); + if (error) throw error; + return data as unknown as AutomationRule; +} + +export async function updateRule( + id: string, + updates: Partial +): Promise { + const supabase = getSupabaseServerClient(); + const { data, error } = await supabase + .from(RULES_TABLE) + .update({ ...updates, updated_at: new Date().toISOString() } as never) + .eq('id', id) + .select() + .single(); + if (error) throw error; + return (data as unknown as AutomationRule) || null; +} + +export async function deleteRule(id: string): Promise { + const supabase = getSupabaseServerClient(); + const { error } = await supabase.from(RULES_TABLE).delete().eq('id', id); + if (error) throw error; +} + +// ---- Logs de atividade ---- +export async function loadRecentLogs(hours = 72): Promise { + try { + const supabase = getSupabaseServerClient(); + const cutoff = new Date(Date.now() - hours * 3600 * 1000).toISOString(); + const { data, error } = await supabase + .from(LOGS_TABLE) + .select('*') + .gte('created_at', cutoff) + .order('created_at', { ascending: false }) + .limit(500); + if (error) throw error; + return (data as unknown as ActivityLog[]) || []; + } catch (e) { + console.warn('[Engine.store] loadRecentLogs fallback:', e instanceof Error ? e.message : e); + return []; + } +} + +export async function saveLogs(logs: ActivityLog[]): Promise { + if (logs.length === 0) return; + try { + const supabase = getSupabaseServerClient(); + const { error } = await supabase.from(LOGS_TABLE).insert(logs as never); + if (error) throw error; + } catch (e) { + console.warn('[Engine.store] saveLogs falhou:', e instanceof Error ? e.message : e); + } +} + +// ---- Config (token da Meta, etc.) ---- +export async function getConfig(key: string): Promise { + try { + const supabase = getSupabaseServerClient(); + const { data, error } = await supabase + .from(CONFIG_TABLE) + .select('value') + .eq('key', key) + .single(); + if (error) throw error; + return (data as { value?: string } | null)?.value ?? null; + } catch { + return null; + } +} + +export async function setConfig(key: string, value: string): Promise { + try { + const supabase = getSupabaseServerClient(); + const { error } = await supabase + .from(CONFIG_TABLE) + .upsert({ key, value, updated_at: new Date().toISOString() } as never, { onConflict: 'key' }); + if (error) throw error; + } catch (e) { + console.warn('[Engine.store] setConfig falhou:', e instanceof Error ? e.message : e); + } +}