feat(creatives): task 3 - ingest and fatigue endpoints (clients, metrics/daily, candidates, mark)

This commit is contained in:
Marcos Fargas
2026-10-12 13:15:48 +02:00
parent 62d1f3a5f6
commit e650c9971e
4 changed files with 402 additions and 0 deletions

View File

@@ -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);
}

View File

@@ -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<string, any[]>();
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,
});
}

View File

@@ -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,
});
}

View File

@@ -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<string, string>();
(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,
});
}