diff --git a/src/app/api/integrations/clients/route.ts b/src/app/api/integrations/clients/route.ts new file mode 100644 index 0000000..789c6ec --- /dev/null +++ b/src/app/api/integrations/clients/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { authenticateIntegrationRequest } from '@/lib/integrations-auth'; +import { createClient } from '@supabase/supabase-js'; + +export async function GET(req: NextRequest) { + const auth = await authenticateIntegrationRequest(req, { requireTenant: false }); + if (!auth.ok) { + return auth.errorResponse!; + } + + const creativesEnabled = req.nextUrl.searchParams.get('creatives_enabled'); + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const adminClient = createClient(supabaseUrl, serviceRoleKey); + + let query = adminClient + .from('tenants') + .select('id, name, slug, meta_ad_account_id, slack_channel_id, drive_folder_id, creatives_enabled'); + + if (creativesEnabled === 'true') { + query = query.eq('creatives_enabled', true); + } + + const { data: clients, error } = await query; + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + // Se devuelven mapeando id tanto a client_id como a tenant_id para compatibilidad total con n8n + const mapped = (clients || []).map((c: any) => ({ + ...c, + client_id: c.id, + tenant_id: c.id, + })); + + return NextResponse.json(mapped); +} diff --git a/src/app/api/integrations/fatigue/candidates/route.ts b/src/app/api/integrations/fatigue/candidates/route.ts new file mode 100644 index 0000000..e668d8e --- /dev/null +++ b/src/app/api/integrations/fatigue/candidates/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { authenticateIntegrationRequest } from '@/lib/integrations-auth'; +import { createClient } from '@supabase/supabase-js'; + +export async function GET(req: NextRequest) { + const auth = await authenticateIntegrationRequest(req, { requireTenant: true }); + if (!auth.ok) { + return auth.errorResponse!; + } + + const tenantId = auth.tenantId!; + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const adminClient = createClient(supabaseUrl, serviceRoleKey); + + // 1. Obtener reglas de fatiga del cliente + const { data: rules, error: rulesErr } = await adminClient + .from('fatigue_rules') + .select('*') + .eq('tenant_id', tenantId) + .maybeSingle(); + + if (rulesErr) { + return NextResponse.json({ error: rulesErr.message }, { status: 500 }); + } + + const lookbackDays = rules?.lookback_days || 7; + + // 2. Calcular fecha de inicio según lookback_days + const sinceDate = new Date(); + sinceDate.setDate(sinceDate.getDate() - lookbackDays); + const sinceStr = sinceDate.toISOString().split('T')[0]; + + // 3. Buscar creatividades actualmente publicadas para este cliente + const { data: publishedCreatives, error: creativesErr } = await adminClient + .from('creatives') + .select('id, tenant_id, campaign_name, adset_id, meta_ad_id, format, angle, hook, primary_text, headline, cta, asset_url, thumbnail_url, status, visibility, created_at') + .eq('tenant_id', tenantId) + .eq('status', 'publicada'); + + if (creativesErr) { + return NextResponse.json({ error: creativesErr.message }, { status: 500 }); + } + + const metaIds = (publishedCreatives || []) + .map((c: any) => c.meta_ad_id) + .filter(Boolean); + + let metricsMap = new Map(); + + if (metaIds.length > 0) { + const { data: metrics, error: metricsErr } = await adminClient + .from('creative_metrics_daily') + .select('*') + .eq('tenant_id', tenantId) + .in('meta_ad_id', metaIds) + .gte('date', sinceStr) + .order('date', { ascending: true }); + + if (metricsErr) { + return NextResponse.json({ error: metricsErr.message }, { status: 500 }); + } + + (metrics || []).forEach((m: any) => { + const list = metricsMap.get(m.meta_ad_id) || []; + list.push(m); + metricsMap.set(m.meta_ad_id, list); + }); + } + + const candidates = (publishedCreatives || []).map((c: any) => ({ + ...c, + client_id: c.tenant_id, + metrics: metricsMap.get(c.meta_ad_id) || [], + })); + + return NextResponse.json({ + client_id: tenantId, + tenant_id: tenantId, + lookback_days: lookbackDays, + rules: rules || null, + candidates, + }); +} diff --git a/src/app/api/integrations/fatigue/mark/route.ts b/src/app/api/integrations/fatigue/mark/route.ts new file mode 100644 index 0000000..be01ba6 --- /dev/null +++ b/src/app/api/integrations/fatigue/mark/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { authenticateIntegrationRequest } from '@/lib/integrations-auth'; +import { createClient } from '@supabase/supabase-js'; + +export async function POST(req: NextRequest) { + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const auth = await authenticateIntegrationRequest(req, { requireTenant: true }); + if (!auth.ok) { + return auth.errorResponse!; + } + + const tenantId = auth.tenantId!; + const creativeId = body.creative_id; + const signals = body.signals || {}; + + if (!creativeId) { + return NextResponse.json({ error: 'creative_id is required' }, { status: 400 }); + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const adminClient = createClient(supabaseUrl, serviceRoleKey); + + // 1. Obtener la creatividad a marcar + const { data: creative, error: getErr } = await adminClient + .from('creatives') + .select('*, tenant:tenants(slug, name)') + .eq('id', creativeId) + .eq('tenant_id', tenantId) + .maybeSingle(); + + if (getErr || !creative) { + return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 }); + } + + const previousStatus = creative.status; + + // 2. Actualizar estado de la creatividad a 'fatigada' + const { error: updateErr } = await adminClient + .from('creatives') + .update({ + status: 'fatigada', + updated_at: new Date().toISOString(), + }) + .eq('id', creativeId); + + if (updateErr) { + return NextResponse.json({ error: updateErr.message }, { status: 500 }); + } + + // 3. Registrar evento en creative_events + await adminClient.from('creative_events').insert({ + tenant_id: tenantId, + creative_id: creativeId, + event_type: 'fatigue_detected', + actor: 'n8n', + from_status: previousStatus, + to_status: 'fatigada', + payload: { signals }, + visibility: 'agency', + }); + + // 4. Consultar fatigue_rules para ver si auto_request_relief es true + const { data: rules } = await adminClient + .from('fatigue_rules') + .select('*') + .eq('tenant_id', tenantId) + .maybeSingle(); + + let generationRequest = null; + + if (rules?.auto_request_relief) { + const avoidAngles = creative.angle ? [creative.angle] : []; + const clientSlug = creative.tenant?.slug || 'bps'; + + const deadline = new Date(); + deadline.setDate(deadline.getDate() + 1); // 24 horas después + + const brief = { + client_id: tenantId, + tenant_id: tenantId, + client_slug: clientSlug, + reason: 'fatiga', + source_creative_id: creativeId, + format: creative.format === 'video' || creative.format === 'carrusel' ? creative.format : 'estatico', + aspect_ratio: '4:5', + variants_requested: 2, + avoid_angles: avoidAngles, + campaign_name: creative.campaign_name || 'General', + adset_id: creative.adset_id || null, + language: 'es', + deadline: deadline.toISOString(), + }; + + const { data: createdReq, error: reqErr } = await adminClient + .from('generation_requests') + .insert({ + tenant_id: tenantId, + reason: 'fatiga', + source_creative_id: creativeId, + brief, + status: 'pendiente', + }) + .select('*') + .single(); + + if (!reqErr && createdReq) { + generationRequest = createdReq; + + // Registrar evento de relevo solicitado + await adminClient.from('creative_events').insert({ + tenant_id: tenantId, + creative_id: creativeId, + event_type: 'generation_requested', + actor: 'system', + payload: { generation_request_id: createdReq.id, brief }, + visibility: 'agency', + }); + } + } + + return NextResponse.json({ + ok: true, + creative_id: creativeId, + status: 'fatigada', + signals, + generation_request: generationRequest, + }); +} diff --git a/src/app/api/integrations/metrics/daily/route.ts b/src/app/api/integrations/metrics/daily/route.ts new file mode 100644 index 0000000..c9a2c2e --- /dev/null +++ b/src/app/api/integrations/metrics/daily/route.ts @@ -0,0 +1,144 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { authenticateIntegrationRequest } from '@/lib/integrations-auth'; +import { createClient } from '@supabase/supabase-js'; + +export async function POST(req: NextRequest) { + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const auth = await authenticateIntegrationRequest(req, { requireTenant: true }); + if (!auth.ok) { + return auth.errorResponse!; + } + + const tenantId = auth.tenantId!; + const rows = Array.isArray(body) ? body : body.metrics || [body]; + + if (!rows.length) { + return NextResponse.json({ error: 'No metrics rows provided' }, { status: 400 }); + } + + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; + const adminClient = createClient(supabaseUrl, serviceRoleKey); + + // 1. Recoger meta_ad_ids únicos de las métricas entrantes + const metaAdIds = Array.from(new Set(rows.map((r: any) => r.meta_ad_id).filter(Boolean))); + + if (!metaAdIds.length) { + return NextResponse.json({ error: 'Rows must include meta_ad_id' }, { status: 400 }); + } + + // 2. Buscar creatividades existentes con esos meta_ad_ids en este tenant + const { data: existingCreatives, error: searchErr } = await adminClient + .from('creatives') + .select('id, meta_ad_id') + .eq('tenant_id', tenantId) + .in('meta_ad_id', metaAdIds); + + if (searchErr) { + return NextResponse.json({ error: searchErr.message }, { status: 500 }); + } + + const creativeMap = new Map(); + (existingCreatives || []).forEach((c: any) => { + creativeMap.set(c.meta_ad_id, c.id); + }); + + // 3. Crear creatividades para los meta_ad_ids que no existan previamente + // Según brief: "Si el meta_ad_id no existe en creatives, crea la fila con status='publicada', created_by='agency', visibility='client'" + const missingMetaIds = metaAdIds.filter((id) => !creativeMap.has(id)); + + if (missingMetaIds.length > 0) { + const toInsert = missingMetaIds.map((metaId) => { + // Buscar información adicional si viene en la fila de métricas + const sampleRow = rows.find((r: any) => r.meta_ad_id === metaId) || {}; + return { + tenant_id: tenantId, + meta_ad_id: metaId, + format: sampleRow.format || 'estatico', + campaign_name: sampleRow.campaign_name || null, + adset_id: sampleRow.adset_id || null, + headline: sampleRow.headline || null, + primary_text: sampleRow.primary_text || null, + status: 'publicada', + visibility: 'client', + created_by: 'agency', + }; + }); + + const { data: inserted, error: insertErr } = await adminClient + .from('creatives') + .insert(toInsert) + .select('id, meta_ad_id'); + + if (insertErr) { + return NextResponse.json({ error: `Error creating placeholder creatives: ${insertErr.message}` }, { status: 500 }); + } + + (inserted || []).forEach((c: any) => { + creativeMap.set(c.meta_ad_id, c.id); + }); + } + + // 4. Preparar upsert de métricas + const metricsToUpsert = rows.map((r: any) => { + const creativeId = creativeMap.get(r.meta_ad_id); + const impressions = Number(r.impressions) || 0; + const clicks = Number(r.clicks) || 0; + const spend = Number(r.spend) || 0; + const results = Number(r.results) || 0; + const reach = Number(r.reach) || impressions; + const frequency = Number(r.frequency) || (reach > 0 ? Number((impressions / reach).toFixed(2)) : 0); + const ctr = r.ctr !== undefined ? Number(r.ctr) : (impressions > 0 ? Number(((clicks / impressions) * 100).toFixed(3)) : 0); + const cpm = r.cpm !== undefined ? Number(r.cpm) : (impressions > 0 ? Number(((spend / impressions) * 1000).toFixed(2)) : 0); + const cpc = r.cpc !== undefined ? Number(r.cpc) : (clicks > 0 ? Number((spend / clicks).toFixed(2)) : 0); + const cpa = r.cpa !== undefined ? Number(r.cpa) : (results > 0 ? Number((spend / results).toFixed(2)) : null); + + return { + tenant_id: tenantId, + creative_id: creativeId, + meta_ad_id: r.meta_ad_id, + date: r.date, + impressions, + reach, + frequency, + clicks, + ctr, + cpm, + cpc, + spend, + results, + cpa, + raw: r.raw || null, + }; + }); + + const { data: upserted, error: upsertErr } = await adminClient + .from('creative_metrics_daily') + .upsert(metricsToUpsert, { onConflict: 'meta_ad_id,date' }) + .select('id, meta_ad_id, date'); + + if (upsertErr) { + return NextResponse.json({ error: upsertErr.message }, { status: 500 }); + } + + // Registrar evento de ingesta en creative_events + await adminClient.from('creative_events').insert({ + tenant_id: tenantId, + event_type: 'metrics_ingested', + actor: 'n8n', + payload: { rows_count: metricsToUpsert.length, meta_ad_ids: metaAdIds }, + visibility: 'agency', + }); + + return NextResponse.json({ + ok: true, + count: upserted?.length || metricsToUpsert.length, + rows: upserted, + }); +}