feat(creatives): task 3 - ingest and fatigue endpoints (clients, metrics/daily, candidates, mark)
This commit is contained in:
39
src/app/api/integrations/clients/route.ts
Normal file
39
src/app/api/integrations/clients/route.ts
Normal 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);
|
||||||
|
}
|
||||||
84
src/app/api/integrations/fatigue/candidates/route.ts
Normal file
84
src/app/api/integrations/fatigue/candidates/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
135
src/app/api/integrations/fatigue/mark/route.ts
Normal file
135
src/app/api/integrations/fatigue/mark/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
144
src/app/api/integrations/metrics/daily/route.ts
Normal file
144
src/app/api/integrations/metrics/daily/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user