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