feat: automação autônoma do engine (n8n) — métricas de compra, persistência e alertas
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 <noreply@anthropic.com>
This commit is contained in:
parent
59773aa2c4
commit
19004d0cc5
9 changed files with 565 additions and 189 deletions
54
docs/automation.sql
Normal file
54
docs/automation.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -97,6 +97,15 @@ export async function GET(request: NextRequest) {
|
||||||
picture: meData.picture?.data?.url || '',
|
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
|
// 5. Redirect to dashboard
|
||||||
return NextResponse.redirect(new URL('/dashboard', baseUrl));
|
return NextResponse.redirect(new URL('/dashboard', baseUrl));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
156
src/app/api/engine/cron/route.ts
Normal file
156
src/app/api/engine/cron/route.ts
Normal file
|
|
@ -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<string, unknown>): Promise<void> {
|
||||||
|
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<string, unknown>));
|
||||||
|
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<ReturnType<typeof createMetaMCPClient>> | 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<string, unknown>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app/api/engine/logs/route.ts
Normal file
22
src/app/api/engine/logs/route.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
@ -1,30 +1,16 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { DEFAULT_RULES } from '@/lib/engine/rules';
|
import { loadRules, createRule, updateRule, deleteRule } from '@/lib/engine/store';
|
||||||
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(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
|
const rules = await loadRules();
|
||||||
return NextResponse.json({ success: true, data: rules });
|
return NextResponse.json({ success: true, data: rules });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const newRule: AutomationRule = {
|
const rule = await createRule(body);
|
||||||
...body,
|
return NextResponse.json({ success: true, data: rule }, { status: 201 });
|
||||||
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 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: error instanceof Error ? error.message : 'Invalid data' },
|
{ success: false, error: error instanceof Error ? error.message : 'Invalid data' },
|
||||||
|
|
@ -37,12 +23,11 @@ export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { id, ...updates } = body;
|
const { id, ...updates } = body;
|
||||||
const index = rules.findIndex((r) => r.id === id);
|
const updated = await updateRule(id, updates);
|
||||||
if (index === -1) {
|
if (!updated) {
|
||||||
return NextResponse.json({ success: false, error: 'Rule not found' }, { status: 404 });
|
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: updated });
|
||||||
return NextResponse.json({ success: true, data: rules[index] });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: false, error: error instanceof Error ? error.message : 'Invalid data' },
|
{ 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) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = request.nextUrl;
|
const id = request.nextUrl.searchParams.get('id');
|
||||||
const id = searchParams.get('id');
|
|
||||||
if (!id) return NextResponse.json({ success: false, error: 'ID required' }, { status: 400 });
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Activity, CheckCircle, Clock, XCircle, Zap, Filter } from 'lucide-react';
|
import { Activity, CheckCircle, Clock, XCircle, Zap, Filter } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/Spinner';
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -13,35 +14,6 @@ interface LogEntry {
|
||||||
status: 'executed' | 'pending' | 'failed' | 'skipped';
|
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 = {
|
const statusConfig = {
|
||||||
executed: { icon: <CheckCircle size={14} />, color: '#4ade80', bg: 'rgba(34,197,94,0.1)', label: 'Executado' },
|
executed: { icon: <CheckCircle size={14} />, color: '#4ade80', bg: 'rgba(34,197,94,0.1)', label: 'Executado' },
|
||||||
pending: { icon: <Clock size={14} />, color: '#fbbf24', bg: 'rgba(245,158,11,0.1)', label: 'Pendente' },
|
pending: { icon: <Clock size={14} />, color: '#fbbf24', bg: 'rgba(245,158,11,0.1)', label: 'Pendente' },
|
||||||
|
|
@ -61,7 +33,24 @@ function timeAgo(dateStr: string): string {
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const [filter, setFilter] = useState<string>('all');
|
const [filter, setFilter] = useState<string>('all');
|
||||||
const logs = filter === 'all' ? DEMO_LOGS : DEMO_LOGS.filter((l) => l.status === filter);
|
const [allLogs, setAllLogs] = useState<LogEntry[]>([]);
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
|
@ -90,10 +79,10 @@ export default function ActivityPage() {
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-4 gap-4 animate-fade-in-up delay-1">
|
<div className="grid grid-cols-4 gap-4 animate-fade-in-up delay-1">
|
||||||
{[
|
{[
|
||||||
{ label: 'Total', value: DEMO_LOGS.length, color: 'var(--accent-soft-fg)' },
|
{ label: 'Total', value: allLogs.length, color: 'var(--accent-soft-fg)' },
|
||||||
{ label: 'Executados', value: DEMO_LOGS.filter((l) => l.status === 'executed').length, color: '#4ade80' },
|
{ label: 'Executados', value: allLogs.filter((l) => l.status === 'executed').length, color: '#4ade80' },
|
||||||
{ label: 'Pendentes', value: DEMO_LOGS.filter((l) => l.status === 'pending').length, color: '#fbbf24' },
|
{ label: 'Pendentes', value: allLogs.filter((l) => l.status === 'pending').length, color: '#fbbf24' },
|
||||||
{ label: 'Pulados', value: DEMO_LOGS.filter((l) => l.status === 'skipped').length, color: '#8888a0' },
|
{ label: 'Pulados', value: allLogs.filter((l) => l.status === 'skipped').length, color: '#8888a0' },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<div key={s.label} className="glass-card-static p-4 text-center">
|
<div key={s.label} className="glass-card-static p-4 text-center">
|
||||||
<p className="text-2xl font-bold" style={{ color: s.color }}>{s.value}</p>
|
<p className="text-2xl font-bold" style={{ color: s.color }}>{s.value}</p>
|
||||||
|
|
@ -108,9 +97,13 @@ export default function ActivityPage() {
|
||||||
<h2 className="text-base font-semibold text-white">Timeline</h2>
|
<h2 className="text-base font-semibold text-white">Timeline</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y" style={{ borderColor: 'var(--overlay-04)' }}>
|
<div className="divide-y" style={{ borderColor: 'var(--overlay-04)' }}>
|
||||||
{logs.length === 0 ? (
|
{loading ? (
|
||||||
|
<div className="p-12 flex justify-center">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
<div className="p-12 text-center">
|
<div className="p-12 text-center">
|
||||||
<p className="text-[var(--muted-fg)]">Nenhuma atividade encontrada</p>
|
<p className="text-[var(--muted-fg)]">Nenhuma atividade ainda. Quando o engine agir ou recomendar algo, aparece aqui.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => {
|
logs.map((log) => {
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,65 @@
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
import type { Client } from '@/lib/mcp/client';
|
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';
|
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<string, unknown> | 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<string, unknown> | null): number {
|
||||||
|
return sumActions(insights, PURCHASE_ACTION_TYPES);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Extract metric value from MCP insights ----
|
// ---- Extract metric value from MCP insights ----
|
||||||
function getMetricValue(insights: Record<string, unknown> | null, metric: string): number {
|
function getMetricValue(insights: Record<string, unknown> | null, metric: string): number {
|
||||||
if (!insights) return 0;
|
if (!insights) return 0;
|
||||||
|
const spend = parseFloat((insights.spend as string) || '0');
|
||||||
|
|
||||||
switch (metric) {
|
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': {
|
case 'cpl': {
|
||||||
const spend = parseFloat((insights.spend as string) || '0');
|
const leads = sumActions(insights, LEAD_ACTION_TYPES);
|
||||||
const leads = parseInt((insights.leads as string) || '0', 10);
|
return leads > 0 ? spend / leads : 0;
|
||||||
if (leads === 0) return 0;
|
|
||||||
return spend / leads;
|
|
||||||
}
|
}
|
||||||
case 'cpc':
|
case 'cpc':
|
||||||
return parseFloat((insights.cpc as string) || '0');
|
return parseFloat((insights.cpc as string) || '0');
|
||||||
case 'ctr':
|
case 'ctr':
|
||||||
return parseFloat((insights.ctr as string) || '0');
|
return parseFloat((insights.ctr as string) || '0');
|
||||||
case 'roas':
|
case 'roas': {
|
||||||
return parseFloat((insights.roas as string) || '0');
|
const roasArr = insights.purchase_roas as Array<{ value?: string }> | undefined;
|
||||||
|
return roasArr?.[0]?.value ? parseFloat(roasArr[0].value) : 0;
|
||||||
|
}
|
||||||
case 'cpm':
|
case 'cpm':
|
||||||
return parseFloat((insights.cpm as string) || '0');
|
return parseFloat((insights.cpm as string) || '0');
|
||||||
case 'spend':
|
case 'spend':
|
||||||
return parseFloat((insights.spend as string) || '0');
|
return spend;
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
@ -63,16 +98,41 @@ function isInCooldown(
|
||||||
(log) =>
|
(log) =>
|
||||||
log.campaign_id === campaignId &&
|
log.campaign_id === campaignId &&
|
||||||
log.rule_id === ruleId &&
|
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
|
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<string> {
|
||||||
|
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 ----
|
// ---- Execute action on campaign via MCP ----
|
||||||
async function executeAction(
|
async function executeAction(
|
||||||
mcpClient: Client,
|
mcpClient: Client,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
dailyBudget: number,
|
|
||||||
insights: Record<string, unknown>,
|
insights: Record<string, unknown>,
|
||||||
rule: AutomationRule
|
rule: AutomationRule
|
||||||
): Promise<{ success: boolean; details: string }> {
|
): Promise<{ success: boolean; details: string }> {
|
||||||
|
|
@ -87,92 +147,29 @@ async function executeAction(
|
||||||
}
|
}
|
||||||
case 'reduce_budget': {
|
case 'reduce_budget': {
|
||||||
const reduction = rule.action_value || 30;
|
const reduction = rule.action_value || 30;
|
||||||
const newBudget = Math.round(dailyBudget * (1 - reduction / 100));
|
const details = await adjustAdSetBudgets(mcpClient, campaignId, 1 - reduction / 100);
|
||||||
if (newBudget < 100) return { success: false, details: 'Budget já no mínimo (R$1.00)' };
|
return { success: true, details: `Budget reduzido ${reduction}% → ${details}` };
|
||||||
await updateCampaignBudget(mcpClient, campaignId, newBudget);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
details: `Budget reduzido ${reduction}%: R$${(dailyBudget / 100).toFixed(2)} → R$${(newBudget / 100).toFixed(2)}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'increase_budget': {
|
case 'increase_budget': {
|
||||||
const increase = rule.action_value || 20;
|
const increase = rule.action_value || 20;
|
||||||
const newBgt = Math.round(dailyBudget * (1 + increase / 100));
|
const details = await adjustAdSetBudgets(mcpClient, campaignId, 1 + increase / 100);
|
||||||
await updateCampaignBudget(mcpClient, campaignId, newBgt);
|
return { success: true, details: `Budget aumentado ${increase}% → ${details}` };
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
details: `Budget aumentado ${increase}%: R$${(dailyBudget / 100).toFixed(2)} → R$${(newBgt / 100).toFixed(2)}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
case 'notify': {
|
case 'notify': {
|
||||||
return {
|
return {
|
||||||
success: true,
|
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': {
|
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);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
details: `Campanha pausada. Nova campanha [AI Gen] criada com sucesso! (ID: ${newCampaign.campaign_id})`,
|
details: 'Campanha pausada. Recomendado: recriar o criativo no app (substituição via IA automática desativada por segurança).',
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (aiError: any) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
details: `Campanha pausada, mas falha ao criar IA: ${aiError.message}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return { success: false, details: 'Ação desconhecida' };
|
return { success: false, details: 'Ação desconhecida' };
|
||||||
|
|
@ -186,10 +183,21 @@ async function executeAction(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Main evaluation function ----
|
// ---- 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(
|
export async function evaluateCampaigns(
|
||||||
mcpClient: Client,
|
mcpClient: Client,
|
||||||
rules: AutomationRule[],
|
rules: AutomationRule[],
|
||||||
recentLogs: ActivityLog[] = []
|
recentLogs: ActivityLog[] = [],
|
||||||
|
mode: AutonomyMode = 'mixed'
|
||||||
): Promise<RuleEvaluation[]> {
|
): Promise<RuleEvaluation[]> {
|
||||||
const enabledRules = rules.filter((r) => r.enabled);
|
const enabledRules = rules.filter((r) => r.enabled);
|
||||||
if (enabledRules.length === 0) return [];
|
if (enabledRules.length === 0) return [];
|
||||||
|
|
@ -204,7 +212,6 @@ export async function evaluateCampaigns(
|
||||||
for (const campaign of activeCampaigns) {
|
for (const campaign of activeCampaigns) {
|
||||||
const campaignId = campaign.id as string;
|
const campaignId = campaign.id as string;
|
||||||
const campaignName = campaign.name as string;
|
const campaignName = campaign.name as string;
|
||||||
const dailyBudget = parseInt((campaign.daily_budget as string) || '0', 10);
|
|
||||||
|
|
||||||
let insights: Record<string, unknown> | null = null;
|
let insights: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
|
|
@ -219,23 +226,14 @@ export async function evaluateCampaigns(
|
||||||
if (!meetsMinimums(insights, rule)) continue;
|
if (!meetsMinimums(insights, rule)) continue;
|
||||||
|
|
||||||
if (isInCooldown(campaignId, rule.id, recentLogs, rule.cooldown_hours)) {
|
if (isInCooldown(campaignId, rule.id, recentLogs, rule.cooldown_hours)) {
|
||||||
evaluations.push({
|
continue; // já agiu recentemente — silencioso pra não poluir o log
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggered = evaluateCondition(metricValue, rule.operator, rule.threshold);
|
if (!evaluateCondition(metricValue, rule.operator, rule.threshold)) continue;
|
||||||
|
|
||||||
if (triggered) {
|
// Regra disparou. Executa ou só recomenda, conforme o modo.
|
||||||
const result = await executeAction(mcpClient, campaignId, dailyBudget, insights!, rule);
|
if (shouldExecute(mode, rule.action)) {
|
||||||
|
const result = await executeAction(mcpClient, campaignId, insights!, rule);
|
||||||
evaluations.push({
|
evaluations.push({
|
||||||
rule,
|
rule,
|
||||||
campaign_id: campaignId,
|
campaign_id: campaignId,
|
||||||
|
|
@ -243,9 +241,22 @@ export async function evaluateCampaigns(
|
||||||
metric_value: metricValue,
|
metric_value: metricValue,
|
||||||
triggered: true,
|
triggered: true,
|
||||||
action_taken: result.success ? rule.action : null,
|
action_taken: result.success ? rule.action : null,
|
||||||
|
recommended_action: rule.action,
|
||||||
details: result.details,
|
details: result.details,
|
||||||
timestamp: new Date().toISOString(),
|
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(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Automation Engine — Rule Types & Defaults
|
// 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 RuleOperator = '>' | '<' | '>=' | '<=';
|
||||||
export type RuleAction = 'pause' | 'reduce_budget' | 'increase_budget' | 'notify' | 'replace_with_ai';
|
export type RuleAction = 'pause' | 'reduce_budget' | 'increase_budget' | 'notify' | 'replace_with_ai';
|
||||||
|
|
||||||
|
|
@ -29,6 +29,8 @@ export interface RuleEvaluation {
|
||||||
metric_value: number;
|
metric_value: number;
|
||||||
triggered: boolean;
|
triggered: boolean;
|
||||||
action_taken: RuleAction | null;
|
action_taken: RuleAction | null;
|
||||||
|
// Ação que a regra recomendou (mesmo quando só notificou, sem executar).
|
||||||
|
recommended_action?: RuleAction | null;
|
||||||
details: string;
|
details: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
@ -47,6 +49,8 @@ export interface ActivityLog {
|
||||||
|
|
||||||
// ---- Metric Labels (PT-BR) ----
|
// ---- Metric Labels (PT-BR) ----
|
||||||
export const METRIC_LABELS: Record<RuleMetric, string> = {
|
export const METRIC_LABELS: Record<RuleMetric, string> = {
|
||||||
|
cpa: 'Custo por Compra (CPA)',
|
||||||
|
purchases: 'Nº de Compras',
|
||||||
cpl: 'Custo por Lead (CPL)',
|
cpl: 'Custo por Lead (CPL)',
|
||||||
cpc: 'Custo por Clique (CPC)',
|
cpc: 'Custo por Clique (CPC)',
|
||||||
ctr: 'Taxa de Clique (CTR %)',
|
ctr: 'Taxa de Clique (CTR %)',
|
||||||
|
|
@ -70,27 +74,29 @@ export const ACTION_LABELS: Record<RuleAction, string> = {
|
||||||
replace_with_ai: 'Pausar e Substituir via IA',
|
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<AutomationRule, 'id' | 'created_at' | 'updated_at'>[] = [
|
export const DEFAULT_RULES: Omit<AutomationRule, 'id' | 'created_at' | 'updated_at'>[] = [
|
||||||
{
|
{
|
||||||
name: 'CPL Alto — Pausar',
|
name: 'Gastou e não vendeu — Pausar',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
metric: 'cpl',
|
metric: 'purchases',
|
||||||
operator: '>',
|
operator: '<',
|
||||||
threshold: 50,
|
threshold: 1, // 0 compras
|
||||||
min_spend: 30,
|
min_spend: 40, // já gastou o suficiente pra concluir que não converte
|
||||||
min_impressions: 1000,
|
min_impressions: 1500,
|
||||||
action: 'pause',
|
action: 'pause',
|
||||||
cooldown_hours: 24,
|
cooldown_hours: 24,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ROAS Baixo — Pausar',
|
name: 'CPA Alto — Pausar',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
metric: 'roas',
|
metric: 'cpa',
|
||||||
operator: '<',
|
operator: '>',
|
||||||
threshold: 0.5,
|
threshold: 30, // custo por compra acima do seu lucro por comprador
|
||||||
min_spend: 50,
|
min_spend: 40,
|
||||||
min_impressions: 2000,
|
min_impressions: 1500,
|
||||||
action: 'pause',
|
action: 'pause',
|
||||||
cooldown_hours: 24,
|
cooldown_hours: 24,
|
||||||
},
|
},
|
||||||
|
|
@ -99,30 +105,30 @@ export const DEFAULT_RULES: Omit<AutomationRule, 'id' | 'created_at' | 'updated_
|
||||||
enabled: true,
|
enabled: true,
|
||||||
metric: 'ctr',
|
metric: 'ctr',
|
||||||
operator: '<',
|
operator: '<',
|
||||||
threshold: 0.5,
|
threshold: 0.6,
|
||||||
min_spend: 20,
|
min_spend: 25,
|
||||||
min_impressions: 2000,
|
min_impressions: 2500,
|
||||||
action: 'pause',
|
action: 'pause',
|
||||||
cooldown_hours: 24,
|
cooldown_hours: 24,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'CPC Alto — Reduzir Budget 30%',
|
name: 'ROAS Baixo — Reduzir Budget 30%',
|
||||||
enabled: false,
|
enabled: false, // em modo misto, vira só aviso
|
||||||
metric: 'cpc',
|
metric: 'roas',
|
||||||
operator: '>',
|
operator: '<',
|
||||||
threshold: 5,
|
threshold: 0.8,
|
||||||
min_spend: 30,
|
min_spend: 60,
|
||||||
min_impressions: 1000,
|
min_impressions: 3000,
|
||||||
action: 'reduce_budget',
|
action: 'reduce_budget',
|
||||||
action_value: 30,
|
action_value: 30,
|
||||||
cooldown_hours: 48,
|
cooldown_hours: 48,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ROAS Excelente — Aumentar Budget 20%',
|
name: 'ROAS Excelente — Aumentar Budget 20%',
|
||||||
enabled: false,
|
enabled: false, // em modo misto, vira só aviso
|
||||||
metric: 'roas',
|
metric: 'roas',
|
||||||
operator: '>',
|
operator: '>',
|
||||||
threshold: 3,
|
threshold: 2.5,
|
||||||
min_spend: 100,
|
min_spend: 100,
|
||||||
min_impressions: 5000,
|
min_impressions: 5000,
|
||||||
action: 'increase_budget',
|
action: 'increase_budget',
|
||||||
|
|
@ -130,3 +136,7 @@ export const DEFAULT_RULES: Omit<AutomationRule, 'id' | 'created_at' | 'updated_
|
||||||
cooldown_hours: 48,
|
cooldown_hours: 48,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Em modo de autonomia "misto", só estas ações são executadas automaticamente.
|
||||||
|
// As demais (orçamento, IA) viram notificação (o engine avisa, mas não age).
|
||||||
|
export const AUTO_EXECUTE_ACTIONS: RuleAction[] = ['pause', 'notify'];
|
||||||
|
|
|
||||||
137
src/lib/engine/store.ts
Normal file
137
src/lib/engine/store.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
// ============================================
|
||||||
|
// Automation Engine — Persistência (Supabase)
|
||||||
|
// ============================================
|
||||||
|
// Tabelas necessárias (rode o SQL em docs/automation.sql no Supabase):
|
||||||
|
// automation_rules, automation_logs, app_config
|
||||||
|
// Tudo com fallback gracioso: se o Supabase estiver fora, cai nas regras padrão
|
||||||
|
// e segue sem quebrar.
|
||||||
|
|
||||||
|
import { getSupabaseServerClient } from '@/lib/supabase/server';
|
||||||
|
import { DEFAULT_RULES } from './rules';
|
||||||
|
import type { AutomationRule, ActivityLog } from './rules';
|
||||||
|
|
||||||
|
const RULES_TABLE = 'automation_rules';
|
||||||
|
const LOGS_TABLE = 'automation_logs';
|
||||||
|
const CONFIG_TABLE = 'app_config';
|
||||||
|
|
||||||
|
function defaultsWithIds(): AutomationRule[] {
|
||||||
|
return DEFAULT_RULES.map((r, i) => ({ ...r, id: `default-${i}` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Regras ----
|
||||||
|
export async function loadRules(): Promise<AutomationRule[]> {
|
||||||
|
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<AutomationRule, 'id' | 'created_at' | 'updated_at'>
|
||||||
|
): Promise<AutomationRule> {
|
||||||
|
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<AutomationRule>
|
||||||
|
): Promise<AutomationRule | null> {
|
||||||
|
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<void> {
|
||||||
|
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<ActivityLog[]> {
|
||||||
|
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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue