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 || '',
|
||||
});
|
||||
|
||||
// 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) {
|
||||
|
|
|
|||
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 { 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(
|
||||
|
|
|
|||
|
|
@ -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: <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' },
|
||||
|
|
@ -61,7 +33,24 @@ function timeAgo(dateStr: string): string {
|
|||
|
||||
export default function ActivityPage() {
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
@ -90,10 +79,10 @@ export default function ActivityPage() {
|
|||
{/* Stats */}
|
||||
<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: '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) => (
|
||||
<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>
|
||||
|
|
@ -108,9 +97,13 @@ export default function ActivityPage() {
|
|||
<h2 className="text-base font-semibold text-white">Timeline</h2>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
|
|
|
|||
|
|
@ -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<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 ----
|
||||
function getMetricValue(insights: Record<string, unknown> | 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<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 ----
|
||||
async function executeAction(
|
||||
mcpClient: Client,
|
||||
campaignId: string,
|
||||
dailyBudget: number,
|
||||
insights: Record<string, unknown>,
|
||||
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<RuleEvaluation[]> {
|
||||
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<string, unknown> | 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RuleMetric, string> = {
|
||||
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<RuleAction, string> = {
|
|||
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'>[] = [
|
||||
{
|
||||
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<AutomationRule, 'id' | 'created_at' | 'updated_
|
|||
enabled: true,
|
||||
metric: 'ctr',
|
||||
operator: '<',
|
||||
threshold: 0.5,
|
||||
min_spend: 20,
|
||||
min_impressions: 2000,
|
||||
threshold: 0.6,
|
||||
min_spend: 25,
|
||||
min_impressions: 2500,
|
||||
action: 'pause',
|
||||
cooldown_hours: 24,
|
||||
},
|
||||
{
|
||||
name: 'CPC Alto — Reduzir Budget 30%',
|
||||
enabled: false,
|
||||
metric: 'cpc',
|
||||
operator: '>',
|
||||
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<AutomationRule, 'id' | 'created_at' | 'updated_
|
|||
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