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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user