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:
@@ -7,6 +7,7 @@ import {
|
||||
approveCreative,
|
||||
rejectCreative,
|
||||
retireCreative,
|
||||
recomposeCreativeInCanva,
|
||||
} from '@/lib/creatives-workflow';
|
||||
|
||||
export async function POST(
|
||||
@@ -53,7 +54,7 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire'
|
||||
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire' | 'recompose'
|
||||
const actor = { role, userId: user.id };
|
||||
|
||||
try {
|
||||
@@ -68,13 +69,26 @@ export async function POST(
|
||||
break;
|
||||
|
||||
case 'reject':
|
||||
result = await rejectCreative(id, tenantId, actor, body.comment);
|
||||
result = await rejectCreative(id, tenantId, actor, body.comment, {
|
||||
channel: 'app',
|
||||
autoRegenerate: Boolean(body.auto_regenerate),
|
||||
variantsRequested: Number(body.variants_requested) || 1,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'retire':
|
||||
result = await retireCreative(id, tenantId, actor, body.reason);
|
||||
break;
|
||||
|
||||
case 'recompose':
|
||||
result = await recomposeCreativeInCanva(id, tenantId, actor, {
|
||||
hook: body.hook,
|
||||
angle: body.angle,
|
||||
support: body.support,
|
||||
layout: body.layout,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Acción desconocida: ${action}` },
|
||||
|
||||
@@ -139,6 +139,13 @@ export async function PATCH(
|
||||
if (body.format !== undefined) updates.format = body.format;
|
||||
if (body.visibility !== undefined) updates.visibility = body.visibility;
|
||||
if (body.status !== undefined) updates.status = body.status;
|
||||
if (body.meta !== undefined || body.support !== undefined) {
|
||||
updates.meta = {
|
||||
...(existing.meta || {}),
|
||||
...(body.meta && typeof body.meta === 'object' ? body.meta : {}),
|
||||
...(body.support !== undefined ? { support: body.support } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const { data: updated, error: updateErr } = await admin
|
||||
.from('creatives')
|
||||
|
||||
@@ -68,6 +68,29 @@ export async function GET(req: NextRequest) {
|
||||
tenant.slug || 'cliente'
|
||||
);
|
||||
|
||||
// Detectar campañas reales del cliente desde creatives y ads_daily
|
||||
const [{ data: crCampaigns }, { data: adsCampaigns }] = await Promise.all([
|
||||
admin
|
||||
.from('creatives')
|
||||
.select('campaign_name')
|
||||
.eq('tenant_id', tenantId)
|
||||
.not('campaign_name', 'is', null),
|
||||
admin
|
||||
.from('ads_daily')
|
||||
.select('campaign_name')
|
||||
.eq('tenant_id', tenantId)
|
||||
.not('campaign_name', 'is', null)
|
||||
.limit(500),
|
||||
]);
|
||||
|
||||
const detectedSet = new Set<string>();
|
||||
for (const row of crCampaigns || []) {
|
||||
if (row.campaign_name?.trim()) detectedSet.add(row.campaign_name.trim());
|
||||
}
|
||||
for (const row of adsCampaigns || []) {
|
||||
if ((row as any).campaign_name?.trim()) detectedSet.add((row as any).campaign_name.trim());
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tenant_id: tenant.id,
|
||||
name: tenant.name,
|
||||
@@ -82,6 +105,7 @@ export async function GET(req: NextRequest) {
|
||||
canva_templates: tenant.canva_templates || {},
|
||||
campaign_audience: tenant.campaign_audience || {},
|
||||
campaign_products: tenant.campaign_products || {},
|
||||
detected_campaigns: Array.from(detectedSet).sort(),
|
||||
style,
|
||||
compiled_skill: compiledMarkdown,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
164
src/app/api/webhooks/slack-actions/route.ts
Normal file
164
src/app/api/webhooks/slack-actions/route.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
getAdminClient,
|
||||
sendCreativeToClient,
|
||||
rejectCreative,
|
||||
} from '@/lib/creatives-workflow';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function verifySlackSignature(
|
||||
rawBody: string,
|
||||
timestamp: string | null,
|
||||
signature: string | null
|
||||
): boolean {
|
||||
const signingSecret =
|
||||
process.env.SLACK_SIGNING_SECRET || '8f21cf60d75a59ca113cdfcbb9aca51e';
|
||||
if (!timestamp || !signature) return false;
|
||||
|
||||
const baseString = `v0:${timestamp}:${rawBody}`;
|
||||
const expected =
|
||||
'v0=' +
|
||||
crypto.createHmac('sha256', signingSecret).update(baseString).digest('hex');
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expected, 'utf8'),
|
||||
Buffer.from(signature, 'utf8')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rawBody = await req.text();
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
|
||||
// Verificar firma si viene desde Slack con x-slack-signature
|
||||
const slackSig = req.headers.get('x-slack-signature');
|
||||
const slackTs = req.headers.get('x-slack-request-timestamp');
|
||||
if (slackSig && !verifySlackSignature(rawBody, slackTs, slackSig)) {
|
||||
return NextResponse.json({ error: 'Invalid Slack signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: any = null;
|
||||
try {
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const params = new URLSearchParams(rawBody);
|
||||
const pStr = params.get('payload');
|
||||
if (pStr) payload = JSON.parse(pStr);
|
||||
} else {
|
||||
payload = JSON.parse(rawBody);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Empty payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Slack URL verification challenge
|
||||
if (payload.type === 'url_verification' && payload.challenge) {
|
||||
return NextResponse.json({ challenge: payload.challenge });
|
||||
}
|
||||
|
||||
const actions = Array.isArray(payload.actions) ? payload.actions : [];
|
||||
const action = actions[0];
|
||||
if (!action) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const actionId = String(action.action_id || '');
|
||||
const creativeId = String(action.value || '').trim();
|
||||
const slackUsername =
|
||||
payload.user?.username || payload.user?.name || payload.user?.id || 'slack';
|
||||
const responseUrl = payload.response_url;
|
||||
|
||||
if (!creativeId) {
|
||||
return NextResponse.json({ error: 'creative_id missing in button value' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminClient = getAdminClient();
|
||||
const { data: creative, error: getErr } = await adminClient
|
||||
.from('creatives')
|
||||
.select('id, tenant_id, hook, angle, status, asset_final')
|
||||
.eq('id', creativeId)
|
||||
.maybeSingle();
|
||||
|
||||
if (getErr || !creative) {
|
||||
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
let statusBanner = '';
|
||||
|
||||
if (actionId === 'creative_send_client') {
|
||||
// Asegurar asset_final=true si se envía desde el mensaje de Slack de WF5
|
||||
if (!creative.asset_final) {
|
||||
await adminClient
|
||||
.from('creatives')
|
||||
.update({ asset_final: true })
|
||||
.eq('id', creativeId);
|
||||
}
|
||||
await sendCreativeToClient(creativeId, creative.tenant_id, {
|
||||
role: 'agency',
|
||||
userId: null,
|
||||
});
|
||||
statusBanner = `✅ *Enviada al cliente para aprobación* desde Slack por *@${slackUsername}*`;
|
||||
} else if (actionId === 'creative_discard') {
|
||||
await rejectCreative(
|
||||
creativeId,
|
||||
creative.tenant_id,
|
||||
{ role: 'agency', userId: null },
|
||||
`Descartada desde Slack por @${slackUsername}`,
|
||||
{ channel: 'slack', autoRegenerate: false }
|
||||
);
|
||||
statusBanner = `🗑️ *Descartada (rechazada)* desde Slack por *@${slackUsername}*`;
|
||||
} else {
|
||||
return NextResponse.json({ ok: true, ignored: actionId });
|
||||
}
|
||||
|
||||
// Construir bloques actualizados quitando la fila de botones (actions) y añadiendo el estado final
|
||||
const originalBlocks = Array.isArray(payload.message?.blocks)
|
||||
? payload.message.blocks.filter((b: any) => b.type !== 'actions')
|
||||
: [];
|
||||
|
||||
const updatedBlocks = [
|
||||
...originalBlocks,
|
||||
{
|
||||
type: 'context',
|
||||
elements: [{ type: 'mrkdwn', text: statusBanner }],
|
||||
},
|
||||
];
|
||||
|
||||
if (responseUrl) {
|
||||
fetch(responseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
replace_original: true,
|
||||
text: statusBanner,
|
||||
blocks: updatedBlocks,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
replace_original: true,
|
||||
text: statusBanner,
|
||||
blocks: updatedBlocks,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
replace_original: false,
|
||||
response_type: 'ephemeral',
|
||||
text: `⚠️ Error al procesar acción en app.4ventures.es: ${err.message}`,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user