feat: sync Meta metrics, objetivos ppto/consecución y simulador por tenant
Rellena ad_daily desde Meta, KPIs con fallback a daily, objetivos de presupuesto vs consecución, y simulador sin TikTok anclado a métricas del cliente. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
44
src/app/api/admin/ads/sync/route.ts
Normal file
44
src/app/api/admin/ads/sync/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/** POST { tenantId?, days? } — sync Meta/Google insights → ad_daily + kpi_weekly */
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user || !isAgencyRole(userRole(user))) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId : undefined;
|
||||
const days =
|
||||
typeof body.days === 'number' && body.days > 0 && body.days <= 90
|
||||
? body.days
|
||||
: 30;
|
||||
|
||||
try {
|
||||
const { results, days: d } = await syncAdsForTenants({ tenantId, days });
|
||||
const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length;
|
||||
const errs = results.filter((r) => r.errors.length).length;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
days: d,
|
||||
synced: ok,
|
||||
withErrors: errs,
|
||||
results,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'sync error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics';
|
||||
import {
|
||||
SimChannel,
|
||||
simulateFromTenantMetrics,
|
||||
} from '@/lib/simulate';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||
@@ -12,10 +19,12 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tenantId =
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const tenantId = ctx.tenantId ||
|
||||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
|
||||
const companyName = user.user_metadata?.company || 'Cliente';
|
||||
if (!tenantId) {
|
||||
const companyName = ctx.label || user.user_metadata?.company || 'Cliente';
|
||||
if (!tenantId && !ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Usuario sin tenant_id en metadata. Re-provisionar cliente.' },
|
||||
{ status: 403 }
|
||||
@@ -121,52 +130,85 @@ export async function POST(req: NextRequest) {
|
||||
normQuery.includes('presupuesto')
|
||||
) {
|
||||
try {
|
||||
if (ctx.portfolioWide || !tenantId) {
|
||||
return reply(
|
||||
'Selecciona un **cliente en Contexto** para simular con sus métricas reales (CPL, cierre y ticket).'
|
||||
);
|
||||
}
|
||||
|
||||
const matchNumber = userMessage.match(/(\d+[\d\.,]*)/);
|
||||
let amount = 1000;
|
||||
if (matchNumber) {
|
||||
amount = parseFloat(matchNumber[1].replace(/\./g, '').replace(',', '.'));
|
||||
}
|
||||
|
||||
let channel: string | null = null;
|
||||
let channel: SimChannel = 'blended';
|
||||
if (normQuery.includes('meta') || normQuery.includes('facebook') || normQuery.includes('instagram')) channel = 'meta';
|
||||
else if (normQuery.includes('google')) channel = 'google';
|
||||
else if (normQuery.includes('tiktok')) channel = 'tiktok';
|
||||
else if (normQuery.includes('linkedin')) channel = 'linkedin';
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
p_tenant_id: tenantId,
|
||||
p_additional_spend: amount,
|
||||
p_target_channel: channel
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const sim = await res.json();
|
||||
const p10 = sim.conservative_p10 || {};
|
||||
const p50 = sim.expected_p50 || {};
|
||||
const p90 = sim.optimistic_p90 || {};
|
||||
const base = sim.baseline || {};
|
||||
|
||||
const channelLabel = (sim.target_channel || 'General').toUpperCase();
|
||||
const text =
|
||||
`🔮 **Simulación de Inversión Adicional (+${amount.toLocaleString('es-ES')} € en ${channelLabel}):**\n\n` +
|
||||
`📈 **Métricas Base Observadas:**\n` +
|
||||
`- **CPL Histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
|
||||
`- **Tasa de Cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
|
||||
`- **Ticket Medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n\n` +
|
||||
`📊 **Proyección por Escenarios Matemáticos:**\n` +
|
||||
`- **Escenario Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p50.roas}x**)\n` +
|
||||
`- **Escenario Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p10.roas}x**)\n` +
|
||||
`- **Escenario Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** facturación (ROAS **${p90.roas}x**)\n\n` +
|
||||
`💡 *Cálculo determinista con la función RPC simulate_budget basada en la atribución de cohorte a 90 días.*`;
|
||||
|
||||
return reply(text);
|
||||
else if (normQuery.includes('tiktok')) {
|
||||
return reply(
|
||||
'TikTok aún no tiene campañas activas en este portafolio. Puedo simular con **Meta**, **Google** o presupuesto **blended** según el histórico del cliente.'
|
||||
);
|
||||
}
|
||||
|
||||
let daily: AdDailyRow[] = [];
|
||||
let weekly: KpiWeeklyRow[] = [];
|
||||
if (agency && ctx.viaView) {
|
||||
const tid = encodeURIComponent(tenantId);
|
||||
const [d, w] = await Promise.all([
|
||||
restGet<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
|
||||
),
|
||||
restGet<KpiWeeklyRow[]>(
|
||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
|
||||
),
|
||||
]);
|
||||
daily = d.data || [];
|
||||
weekly = w.data || [];
|
||||
} else {
|
||||
const [{ data: d }, { data: w }] = await Promise.all([
|
||||
supabase
|
||||
.from('ad_daily')
|
||||
.select('*')
|
||||
.order('date', { ascending: false })
|
||||
.limit(400),
|
||||
supabase
|
||||
.from('kpi_weekly')
|
||||
.select('*')
|
||||
.order('week', { ascending: false })
|
||||
.limit(52),
|
||||
]);
|
||||
daily = (d as AdDailyRow[]) || [];
|
||||
weekly = (w as KpiWeeklyRow[]) || [];
|
||||
}
|
||||
|
||||
const sim = simulateFromTenantMetrics({
|
||||
spend: Number.isFinite(amount) && amount > 0 ? amount : 1000,
|
||||
channel,
|
||||
daily,
|
||||
weekly,
|
||||
});
|
||||
const p10 = sim.conservative_p10;
|
||||
const p50 = sim.expected_p50;
|
||||
const p90 = sim.optimistic_p90;
|
||||
const base = sim.baseline;
|
||||
const channelLabel = (sim.target_channel || 'blended').toUpperCase();
|
||||
const text =
|
||||
`🔮 **Simulación (+${amount.toLocaleString('es-ES')} € · ${channelLabel}) — ${companyName}:**\n\n` +
|
||||
`📈 **Baseline del cliente (ad_daily + kpi_weekly):**\n` +
|
||||
`- **CPL histórico:** ${Number(base.historical_cpl || 0).toFixed(2)} €\n` +
|
||||
`- **Tasa de cierre:** ${Number(base.conversion_rate_pct || 0).toFixed(1)}%\n` +
|
||||
`- **Ticket medio:** ${Number(base.avg_ticket || 0).toLocaleString('es-ES', { minimumFractionDigits: 2 })} €\n` +
|
||||
`- **Muestra:** ${Number(base.sample_spend || 0).toLocaleString('es-ES')} € · ${base.sample_leads || 0} leads\n\n` +
|
||||
`📊 **Proyección:**\n` +
|
||||
`- **Esperado (p50):** +${p50.leads} leads | +${p50.deals} clientes | **+${Number(p50.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p50.roas}x**)\n` +
|
||||
`- **Conservador (p10):** +${p10.leads} leads | +${p10.deals} clientes | **+${Number(p10.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p10.roas}x**)\n` +
|
||||
`- **Optimista (p90):** +${p90.leads} leads | +${p90.deals} clientes | **+${Number(p90.revenue || 0).toLocaleString('es-ES')} €** (ROAS **${p90.roas}x**)\n\n` +
|
||||
`💡 *Estimación con métricas reales del cliente en contexto. Sin TikTok (sin campañas activas).*`;
|
||||
|
||||
return reply(text);
|
||||
} catch (e) {
|
||||
console.error('Error in simulate_budget chat:', e);
|
||||
console.error('Error in tenant simulate chat:', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { AdDailyRow, KpiWeeklyRow } from '@/lib/metrics';
|
||||
import {
|
||||
SimChannel,
|
||||
simulateFromTenantMetrics,
|
||||
} from '@/lib/simulate';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function loadTenantMetrics(tenantId: string, agencyView: boolean) {
|
||||
let daily: AdDailyRow[] = [];
|
||||
let weekly: KpiWeeklyRow[] = [];
|
||||
|
||||
if (agencyView) {
|
||||
const tid = encodeURIComponent(tenantId);
|
||||
const [d, w] = await Promise.all([
|
||||
restGet<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=400`
|
||||
),
|
||||
restGet<KpiWeeklyRow[]>(
|
||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.desc&limit=52`
|
||||
),
|
||||
]);
|
||||
daily = d.data || [];
|
||||
weekly = w.data || [];
|
||||
} else {
|
||||
const supabase = await createClient();
|
||||
const [{ data: d }, { data: w }] = await Promise.all([
|
||||
supabase
|
||||
.from('ad_daily')
|
||||
.select('*')
|
||||
.order('date', { ascending: false })
|
||||
.limit(400),
|
||||
supabase
|
||||
.from('kpi_weekly')
|
||||
.select('*')
|
||||
.order('week', { ascending: false })
|
||||
.limit(52),
|
||||
]);
|
||||
daily = (d as AdDailyRow[]) || [];
|
||||
weekly = (w as KpiWeeklyRow[]) || [];
|
||||
}
|
||||
return { daily, weekly };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://supabase.4ventures.es';
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim() || '';
|
||||
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
@@ -13,42 +55,50 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tenantId =
|
||||
user.app_metadata?.tenant_id || user.user_metadata?.tenant_id || null;
|
||||
if (!tenantId) {
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Usuario sin tenant_id' },
|
||||
{ status: 403 }
|
||||
{ error: 'Selecciona un cliente en Contexto para simular.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const keyForRpc = serviceKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const spend = parseFloat(body.spend || 1000);
|
||||
const channel = body.channel || 'meta';
|
||||
let channel = String(body.channel || 'blended').toLowerCase();
|
||||
|
||||
const res = await fetch(`${supabaseUrl}/rest/v1/rpc/simulate_budget`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
apikey: keyForRpc,
|
||||
Authorization: `Bearer ${keyForRpc}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
p_tenant_id: tenantId,
|
||||
p_additional_spend: spend,
|
||||
p_target_channel: channel === 'blended' ? null : channel,
|
||||
}),
|
||||
if (channel === 'tiktok') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'TikTok no está activo aún en este portafolio. Usa Meta, Google o blended.',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (!['meta', 'google', 'blended'].includes(channel)) {
|
||||
channel = 'blended';
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const { daily, weekly } = await loadTenantMetrics(
|
||||
ctx.tenantId,
|
||||
agency && ctx.viaView
|
||||
);
|
||||
|
||||
const sim = simulateFromTenantMetrics({
|
||||
spend: Number.isFinite(spend) && spend > 0 ? spend : 1000,
|
||||
channel: channel as SimChannel,
|
||||
daily,
|
||||
weekly,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
const err = await res.text();
|
||||
return NextResponse.json({ error: err }, { status: 500 });
|
||||
return NextResponse.json({
|
||||
...sim,
|
||||
tenant_label: ctx.label,
|
||||
hint: 'Estimación con CPL, cierre y ticket del cliente en contexto (ad_daily + kpi_weekly).',
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
|
||||
Reference in New Issue
Block a user