feat(creatives): implement 7 system improvements (Canva recompose, Slack actions, reject feedback loop, 9:16 stories, Meta campaign auto-detect, Claude Vision QA, angle analytics)

This commit is contained in:
Marcos Fargas
2026-10-13 02:41:02 +02:00
parent 142933a654
commit b13270d9ac
10 changed files with 1086 additions and 116 deletions

View File

@@ -17,24 +17,21 @@ export async function GET(
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
// 1. Obtener la creatividad (la fatigada u objetivo)
const { data: creative, error: cErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', id)
.eq('tenant_id', tenantId)
.maybeSingle();
if (cErr || !creative) {
return NextResponse.json(
{ error: 'Creative not found' },
{ status: 404 }
);
// 1. Obtener la creatividad (la fatigada u objetivo, permitiendo null/none para peticiones manuales)
let creative: any = null;
if (id && id !== 'null' && id !== 'none' && id !== 'undefined') {
const { data: cData } = await adminClient
.from('creatives')
.select('*')
.eq('id', id)
.eq('tenant_id', tenantId)
.maybeSingle();
creative = cData || null;
}
// 2. Obtener métricas de la creatividad
// 2. Obtener métricas de la creatividad si existe
let metrics: any[] = [];
if (creative.meta_ad_id) {
if (creative?.meta_ad_id) {
const { data: mData } = await adminClient
.from('creative_metrics_daily')
.select('*')
@@ -51,64 +48,146 @@ export async function GET(
.eq('tenant_id', tenantId)
.maybeSingle();
// 4. Obtener las 3 mejores creatividades históricas del cliente por CTR con su angle y hook
// Para calcular el CTR promedio ponderado o histórico de cada creatividad con métricas:
const { data: topCreativesWithMetrics } = await adminClient
.from('creative_metrics_daily')
.select('creative_id, impressions, clicks, spend, results')
.eq('tenant_id', tenantId)
.gt('impressions', 100);
// 4. Obtener todas las creatividades y métricas para top_creatives y rendimiento por ángulo
const [{ data: allCreatives }, { data: allMetrics }, { data: recentRejections }] =
await Promise.all([
adminClient
.from('creatives')
.select('id, format, angle, hook, primary_text, headline, cta, asset_url, status')
.eq('tenant_id', tenantId),
adminClient
.from('creative_metrics_daily')
.select('creative_id, impressions, clicks, spend, results')
.eq('tenant_id', tenantId),
adminClient
.from('approvals')
.select('creative_id, actor_role, comment, created_at')
.eq('tenant_id', tenantId)
.eq('decision', 'rechazada')
.order('created_at', { ascending: false })
.limit(5),
]);
const creativePerformance = new Map<string, { impressions: number; clicks: number }>();
(topCreativesWithMetrics || []).forEach((row: any) => {
const current = creativePerformance.get(row.creative_id) || { impressions: 0, clicks: 0 };
const creativeMap = new Map<string, any>();
for (const c of allCreatives || []) {
creativeMap.set(c.id, c);
}
const creativePerformance = new Map<
string,
{ impressions: number; clicks: number; spend: number; results: number }
>();
for (const row of allMetrics || []) {
const current = creativePerformance.get(row.creative_id) || {
impressions: 0,
clicks: 0,
spend: 0,
results: 0,
};
current.impressions += Number(row.impressions) || 0;
current.clicks += Number(row.clicks) || 0;
current.spend += Number(row.spend) || 0;
current.results += Number(row.results) || 0;
creativePerformance.set(row.creative_id, current);
});
}
const rankedIds = Array.from(creativePerformance.entries())
.filter(([_, stats]) => stats.impressions > 0)
.filter(([_, stats]) => stats.impressions > 100)
.map(([creative_id, stats]) => ({
creative_id,
ctr: (stats.clicks / stats.impressions) * 100,
impressions: stats.impressions,
clicks: stats.clicks,
spend: stats.spend,
results: stats.results,
}))
.sort((a, b) => b.ctr - a.ctr)
.slice(0, 3);
let topCreatives: any[] = [];
if (rankedIds.length > 0) {
const ids = rankedIds.map((r) => r.creative_id);
const { data: topRows } = await adminClient
.from('creatives')
.select('id, format, angle, hook, primary_text, headline, cta, asset_url')
.in('id', ids);
topCreatives = (topRows || []).map((row: any) => {
const stats = rankedIds.find((r) => r.creative_id === row.id);
return {
...row,
historical_ctr: stats ? Number(stats.ctr.toFixed(3)) : null,
impressions: stats?.impressions || 0,
clicks: stats?.clicks || 0,
};
});
topCreatives = rankedIds
.map((r) => {
const row = creativeMap.get(r.creative_id);
if (!row) return null;
return {
...row,
historical_ctr: Number(r.ctr.toFixed(3)),
impressions: r.impressions,
clicks: r.clicks,
};
})
.filter(Boolean);
}
// Si no hubiera métricas suficientes, buscar las últimas creatividades con angle y hook
if (topCreatives.length === 0) {
const { data: fallbackRows } = await adminClient
.from('creatives')
.select('id, format, angle, hook, primary_text, headline, cta, asset_url')
.eq('tenant_id', tenantId)
.not('angle', 'is', null)
.limit(3);
topCreatives = fallbackRows || [];
topCreatives = (allCreatives || [])
.filter((c) => Boolean(c.angle))
.slice(0, 3);
}
// 5. Obtener datos del cliente (drive_folder_id, slug, nombre, mapas)
// 4b. Rendimiento agregado por ángulo creativo (Improvement #7)
const angleAgg = new Map<
string,
{
angle: string;
creatives_count: number;
fatigued_count: number;
impressions: number;
clicks: number;
spend: number;
results: number;
}
>();
for (const c of allCreatives || []) {
const angleKey = (c.angle || '').trim();
if (!angleKey) continue;
const entry = angleAgg.get(angleKey) || {
angle: angleKey,
creatives_count: 0,
fatigued_count: 0,
impressions: 0,
clicks: 0,
spend: 0,
results: 0,
};
entry.creatives_count += 1;
if (c.status === 'fatigada') entry.fatigued_count += 1;
const perf = creativePerformance.get(c.id);
if (perf) {
entry.impressions += perf.impressions;
entry.clicks += perf.clicks;
entry.spend += perf.spend;
entry.results += perf.results;
}
angleAgg.set(angleKey, entry);
}
const anglePerformance = Array.from(angleAgg.values())
.map((a) => ({
...a,
ctr: a.impressions > 0 ? Number(((a.clicks / a.impressions) * 100).toFixed(2)) : 0,
cpa: a.results > 0 ? Number((a.spend / a.results).toFixed(2)) : null,
spend: Number(a.spend.toFixed(2)),
}))
.sort((a, b) => b.ctr - a.ctr);
// 4c. Últimos rechazos con feedback (Improvement #3)
const rejectedFeedback = (recentRejections || []).map((rej: any) => {
const c = creativeMap.get(rej.creative_id);
return {
creative_id: rej.creative_id,
rejected_by: rej.actor_role,
comment: rej.comment,
hook: c?.hook || null,
angle: c?.angle || null,
headline: c?.headline || null,
created_at: rej.created_at,
};
});
// 5. Obtener datos del cliente
const { data: tenant } = await adminClient
.from('tenants')
.select('id, name, slug, drive_folder_id, meta_ad_account_id, campaign_audience, campaign_products')
@@ -122,6 +201,8 @@ export async function GET(
rules: rules || null,
top_creatives: topCreatives,
top_historical_creatives: topCreatives,
angle_performance: anglePerformance,
rejected_feedback: rejectedFeedback,
client: {
id: tenantId,
name: tenant?.name || null,

View File

@@ -121,11 +121,23 @@ export async function PATCH(
if (body.asset_url !== undefined) updates.asset_url = body.asset_url;
if (body.thumbnail_url !== undefined) updates.thumbnail_url = body.thumbnail_url;
if (body.asset_final !== undefined) updates.asset_final = Boolean(body.asset_final);
const existingMeta =
creative.meta && typeof creative.meta === 'object' ? creative.meta : {};
const preservedRawUrl =
(existingMeta as any).raw_image_url ||
(!creative.asset_final && creative.asset_url ? creative.asset_url : undefined);
if (body.meta !== undefined && typeof body.meta === 'object' && body.meta !== null) {
updates.meta = {
...(creative.meta && typeof creative.meta === 'object' ? creative.meta : {}),
...existingMeta,
...(preservedRawUrl ? { raw_image_url: preservedRawUrl } : {}),
...body.meta,
};
} else if (preservedRawUrl && body.asset_final) {
updates.meta = {
...existingMeta,
raw_image_url: preservedRawUrl,
};
}
const { data: updated, error: updateErr } = await adminClient

View File

@@ -54,6 +54,9 @@ export async function POST(req: NextRequest) {
if (c.composition_notes) {
meta.composition_notes = c.composition_notes;
}
if (c.asset_url && !c.asset_final && !meta.raw_image_url) {
meta.raw_image_url = c.asset_url;
}
return {
tenant_id: tenantId,
campaign_name: c.campaign_name || null,