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

@@ -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}` },

View File

@@ -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')

View File

@@ -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,
});

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,

View 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 }
);
}
}

View File

@@ -135,39 +135,129 @@ export default function AgencyCreativesView({
const [detailCreative, setDetailCreative] = useState<CreativeItem | null>(null);
const [detailEvents, setDetailEvents] = useState<any[]>([]);
// Edición inline de ángulo y hook
// Edición inline de ángulo, hook y support + Re-composición en Canva
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
const [inlineAngle, setInlineAngle] = useState('');
const [inlineHook, setInlineHook] = useState('');
const [inlineSupport, setInlineSupport] = useState('');
const [savingInline, setSavingInline] = useState(false);
const [recomposingId, setRecomposingId] = useState<string | null>(null);
const handleSaveInline = async (creativeId: string) => {
// Modal de rechazo con feedback para Claude + auto-regenerar
const [rejectModalCreative, setRejectModalCreative] = useState<CreativeItem | null>(null);
const [rejectComment, setRejectComment] = useState('');
const [rejectAutoRegenerate, setRejectAutoRegenerate] = useState(true);
const [isRejecting, setIsRejecting] = useState(false);
const handleSaveInline = async (creativeId: string, alsoRecompose = false) => {
setSavingInline(true);
try {
const res = await fetch(`/api/creatives/${creativeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
angle: inlineAngle.trim() || null,
hook: inlineHook.trim() || null,
}),
});
if (!res.ok) {
if (alsoRecompose) {
setRecomposingId(creativeId);
const res = await fetch(`/api/creatives/${creativeId}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'recompose',
angle: inlineAngle.trim() || null,
hook: inlineHook.trim() || null,
support: inlineSupport.trim() || null,
}),
});
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Error al guardar');
if (!res.ok) throw new Error(data.error || 'Error al re-componer en Canva');
setInlineEditingId(null);
await loadData();
alert('¡Re-composición enviada a Canva (WF5)! En ~15 segundos tendrás la nueva imagen lista sin coste de IA.');
} else {
const res = await fetch(`/api/creatives/${creativeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
angle: inlineAngle.trim() || null,
hook: inlineHook.trim() || null,
support: inlineSupport.trim() || null,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Error al guardar');
}
setCreatives((prev) =>
prev.map((c) =>
c.id === creativeId
? {
...c,
angle: inlineAngle.trim() || null,
hook: inlineHook.trim() || null,
meta: { ...(c.meta || {}), support: inlineSupport.trim() || null },
}
: c
)
);
setInlineEditingId(null);
}
setCreatives((prev) =>
prev.map((c) =>
c.id === creativeId
? { ...c, angle: inlineAngle.trim() || null, hook: inlineHook.trim() || null }
: c
)
);
setInlineEditingId(null);
} catch (err: any) {
alert(err.message);
} finally {
setSavingInline(false);
setRecomposingId(null);
}
};
const handleRecomposeQuick = async (c: CreativeItem) => {
setRecomposingId(c.id);
try {
const res = await fetch(`/api/creatives/${c.id}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'recompose',
hook: c.hook,
angle: c.angle,
support: c.meta?.support || c.headline,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Error al re-componer en Canva');
await loadData();
alert('¡Re-composición enviada a Canva (WF5)! Refresca en 15 segundos para ver el nuevo diseño.');
} catch (err: any) {
alert(err.message);
} finally {
setRecomposingId(null);
}
};
const handleConfirmReject = async () => {
if (!rejectModalCreative) return;
if (!rejectComment.trim()) {
alert('Escribe un motivo de rechazo para que Claude aprenda de esta corrección.');
return;
}
setIsRejecting(true);
try {
const res = await fetch(`/api/creatives/${rejectModalCreative.id}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'reject',
comment: rejectComment.trim(),
auto_regenerate: rejectAutoRegenerate,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || 'Error al rechazar creatividad');
setRejectModalCreative(null);
setRejectComment('');
await loadData();
if (rejectAutoRegenerate) {
alert('Creatividad rechazada y nueva variante solicitada automáticamente a Claude (WF3) aplicando tu corrección.');
}
} catch (err: any) {
alert(err.message);
} finally {
setIsRejecting(false);
}
};
@@ -229,6 +319,15 @@ export default function AgencyCreativesView({
// 3. Acción: Cambiar estado manual
const handleChangeStatus = async (creativeId: string, nextStatus: string) => {
if (nextStatus === 'rechazada') {
const found = creatives.find((c) => c.id === creativeId);
if (found) {
setRejectModalCreative(found);
setRejectComment('');
setRejectAutoRegenerate(true);
return;
}
}
try {
const res = await fetch(`/api/creatives/${creativeId}`, {
method: 'PATCH',
@@ -379,6 +478,57 @@ export default function AgencyCreativesView({
return groups;
}, [creatives]);
// Analítica agregada por Ángulo Creativo (#7)
const anglePerformanceStats = useMemo(() => {
const byAngle: Record<
string,
{
angle: string;
creatives_count: number;
fatigued_count: number;
approved_count: number;
impressions: number;
clicks: number;
spend: number;
results: number;
}
> = {};
for (const c of creatives) {
const ang = (c.angle || 'sin_angulo').trim();
if (!byAngle[ang]) {
byAngle[ang] = {
angle: ang,
creatives_count: 0,
fatigued_count: 0,
approved_count: 0,
impressions: 0,
clicks: 0,
spend: 0,
results: 0,
};
}
byAngle[ang].creatives_count += 1;
if (c.status === 'fatigada') byAngle[ang].fatigued_count += 1;
if (c.status === 'aprobada' || c.status === 'publicada') byAngle[ang].approved_count += 1;
if (c.metrics) {
byAngle[ang].impressions += Number(c.metrics.impressions || 0);
byAngle[ang].clicks += Number(c.metrics.clicks || 0);
byAngle[ang].spend += Number(c.metrics.spend || 0);
byAngle[ang].results += Number(c.metrics.results || 0);
}
}
return Object.values(byAngle)
.map((a) => ({
...a,
spend: Number(a.spend.toFixed(2)),
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,
}))
.sort((a, b) => b.ctr - a.ctr || b.creatives_count - a.creatives_count);
}, [creatives]);
return (
<div className="space-y-6">
{/* Cabecera */}
@@ -499,8 +649,8 @@ export default function AgencyCreativesView({
</div>
)}
{/* Badge asset_final */}
<div className="absolute top-2 left-2">
{/* Badge asset_final + QA Visual */}
<div className="absolute top-2 left-2 flex flex-wrap items-center gap-1">
{c.asset_final ? (
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-600/90 backdrop-blur-xs px-2 py-0.5 text-[10px] font-semibold text-white shadow-xs">
<FileCheck2 className="w-3 h-3" />
@@ -512,10 +662,35 @@ export default function AgencyCreativesView({
Requiere final
</span>
)}
{c.meta?.qa_visual?.quality_score ? (
<span
title={c.meta.qa_visual.feedback || 'QA Visual por Claude Vision'}
className={`inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-[10px] font-bold text-white shadow-xs backdrop-blur-xs ${
Number(c.meta.qa_visual.quality_score) >= 8
? 'bg-indigo-600/90'
: 'bg-amber-600/90'
}`}
>
QA {c.meta.qa_visual.quality_score}/10
</span>
) : null}
</div>
{/* Badge Formato */}
<div className="absolute top-2 right-2">
{/* Badge Formato + 9:16 Stories */}
<div className="absolute top-2 right-2 flex items-center gap-1">
{c.meta?.stories_asset_url ? (
<a
href={c.meta.stories_asset_url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
title="Abrir versión 9:16 Stories/Reels"
className="rounded-md bg-purple-600/90 hover:bg-purple-700 backdrop-blur-xs px-1.5 py-0.5 text-[10px] font-semibold text-white"
>
9:16 Stories ↗
</a>
) : null}
<span className="rounded-md bg-black/60 backdrop-blur-xs px-1.5 py-0.5 text-[10px] font-semibold text-white uppercase">
{c.format || 'estatico'}
</span>
@@ -542,17 +717,29 @@ export default function AgencyCreativesView({
</div>
<div>
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
Hook / Gancho
Hook / Titular en Canva (máx 6 palabras)
</label>
<textarea
rows={2}
value={inlineHook}
onChange={(e) => setInlineHook(e.target.value)}
placeholder="Gancho de la creatividad o nombre del anuncio..."
placeholder="Gancho de la creatividad en la imagen..."
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white resize-none"
/>
</div>
<div className="flex items-center justify-end gap-1.5 pt-1">
<div>
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
Support / Subtítulo en Canva
</label>
<input
type="text"
value={inlineSupport}
onChange={(e) => setInlineSupport(e.target.value)}
placeholder="Subtítulo de apoyo en la plantilla de Canva..."
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white"
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-1.5 pt-1">
<button
type="button"
disabled={savingInline}
@@ -564,10 +751,20 @@ export default function AgencyCreativesView({
<button
type="button"
disabled={savingInline}
onClick={() => void handleSaveInline(c.id)}
className="px-2.5 py-1 rounded text-[11px] font-semibold text-white bg-brand-500 hover:bg-brand-600"
onClick={() => void handleSaveInline(c.id, false)}
className="px-2.5 py-1 rounded text-[11px] font-semibold text-gray-700 bg-white border border-gray-300 hover:bg-gray-100"
>
{savingInline ? 'Guardando…' : 'Guardar'}
{savingInline && recomposingId !== c.id ? 'Guardando…' : 'Solo guardar'}
</button>
<button
type="button"
disabled={savingInline}
onClick={() => void handleSaveInline(c.id, true)}
className="px-2.5 py-1 rounded text-[11px] font-semibold text-white bg-brand-600 hover:bg-brand-700 inline-flex items-center gap-1"
title="Guarda el nuevo texto y vuelve a maquetar el PNG en Canva usando la foto original de Freepik (coste 0 de IA)"
>
<RefreshCw className={`w-3 h-3 ${recomposingId === c.id ? 'animate-spin' : ''}`} />
{recomposingId === c.id ? 'Re-componiendo…' : 'Guardar + Canva'}
</button>
</div>
</div>
@@ -595,14 +792,23 @@ export default function AgencyCreativesView({
setInlineEditingId(c.id);
setInlineAngle(c.angle || '');
setInlineHook(c.hook || '');
setInlineSupport(c.meta?.support || c.headline || '');
}}
className="text-gray-400 hover:text-brand-600 shrink-0 p-0.5 rounded transition-colors"
title="Editar Hook y Ángulo"
title="Editar Hook, Support y Re-componer en Canva"
>
<Pencil className="w-3 h-3" />
</button>
</div>
{/* Support si existe */}
{c.meta?.support ? (
<p className="text-[10px] text-gray-500">
<span className="font-semibold text-gray-700">Support Canva: </span>
{c.meta.support}
</p>
) : null}
{/* Montaje / composition_notes debajo del hook */}
{c.meta?.composition_notes && (
<div className="rounded-lg bg-amber-50/90 p-2 border border-amber-200/60 text-[11px] space-y-0.5">
@@ -615,6 +821,14 @@ export default function AgencyCreativesView({
</div>
)}
{/* Feedback de QA Visual si hay sugerencia */}
{c.meta?.qa_visual?.feedback ? (
<div className="rounded-lg bg-indigo-50/80 p-1.5 border border-indigo-200/60 text-[10px] text-indigo-900">
<span className="font-bold">QA IA ({c.meta.qa_visual.quality_score}/10): </span>
{c.meta.qa_visual.feedback}
</div>
) : null}
{/* Copy principal */}
{c.primary_text ? (
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed">
@@ -636,6 +850,7 @@ export default function AgencyCreativesView({
setInlineEditingId(c.id);
setInlineAngle('');
setInlineHook(c.hook || '');
setInlineSupport(c.meta?.support || c.headline || '');
}}
className="rounded-md bg-amber-50 hover:bg-amber-100 px-1.5 py-0.5 text-amber-700 font-medium border border-amber-200"
>
@@ -720,6 +935,37 @@ export default function AgencyCreativesView({
) : null}
</div>
{/* Segunda fila: Re-componer en Canva (1 clic) y Rechazar con feedback */}
{(col.id === 'borrador' || col.id === 'pendiente_aprobacion') && (
<div className="flex items-center gap-1.5">
{(c.meta?.raw_image_url || c.asset_url) ? (
<button
type="button"
disabled={recomposingId === c.id}
onClick={() => void handleRecomposeQuick(c)}
title="Vuelve a maquetar el PNG en Canva con el Hook y Support actuales usando la foto limpia de Freepik (coste 0 de IA)"
className="flex-1 inline-flex items-center justify-center gap-1 rounded-lg border border-indigo-200 bg-indigo-50/70 py-1 text-[10px] font-semibold text-indigo-700 hover:bg-indigo-100 transition-colors"
>
<RefreshCw className={`w-3 h-3 ${recomposingId === c.id ? 'animate-spin' : ''}`} />
{recomposingId === c.id ? 'Componiendo…' : 'Re-componer Canva'}
</button>
) : null}
<button
type="button"
onClick={() => {
setRejectModalCreative(c);
setRejectComment('');
setRejectAutoRegenerate(true);
}}
className="inline-flex items-center justify-center gap-1 rounded-lg border border-red-200 bg-red-50/60 px-2 py-1 text-[10px] font-semibold text-red-700 hover:bg-red-100 transition-colors"
title="Rechazar con feedback para Claude y pedir nueva variante"
>
<XCircle className="w-3 h-3" />
Rechazar
</button>
</div>
)}
{/* Selector rápido para mover de columna */}
<div className="flex items-center justify-between text-[10px] text-gray-500 pt-1">
<select
@@ -890,8 +1136,8 @@ export default function AgencyCreativesView({
</Surface>
</div>
{/* Listado de Creatividades Fatigadas */}
<div className="lg:col-span-2">
{/* Listado de Creatividades Fatigadas + Analítica por Ángulo Creativo */}
<div className="lg:col-span-2 space-y-6">
<Surface className="p-5 space-y-4">
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
<div>
@@ -958,6 +1204,83 @@ export default function AgencyCreativesView({
)}
</div>
</Surface>
{/* Analítica Histórica por Ángulo Creativo (#7) */}
<Surface className="p-5 space-y-4">
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
<div>
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
<BarChart2 className="w-4 h-4 text-brand-600" />
Rendimiento Histórico por Ángulo Creativo
</h3>
<p className="text-xs text-gray-500">
Métricas agregadas de CTR, CPA, inversión y fatiga por ángulo. Estos datos se inyectan automáticamente a Claude en WF3 para priorizar los ángulos ganadores.
</p>
</div>
<span className="rounded-full bg-purple-50 px-2.5 py-0.5 text-xs font-semibold text-purple-700 border border-purple-200">
{anglePerformanceStats.length} ángulos
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="bg-gray-50 text-gray-500 uppercase tracking-wider font-semibold border-b border-gray-200">
<tr>
<th className="py-2.5 px-3">Ángulo Creativo</th>
<th className="py-2.5 px-3 text-center">Piezas</th>
<th className="py-2.5 px-3 text-center">Fatigadas</th>
<th className="py-2.5 px-3 text-right">Impresiones</th>
<th className="py-2.5 px-3 text-right">CTR Medio</th>
<th className="py-2.5 px-3 text-right">CPA Medio</th>
<th className="py-2.5 px-3 text-right">Gasto Total</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{anglePerformanceStats.length === 0 ? (
<tr>
<td colSpan={7} className="py-6 text-center text-gray-400">
Sin ángulos registrados aún
</td>
</tr>
) : (
anglePerformanceStats.map((row) => (
<tr key={row.angle} className="hover:bg-gray-50/60">
<td className="py-2.5 px-3">
<span className="inline-flex items-center rounded-md bg-purple-50 px-2 py-0.5 font-semibold text-purple-700 border border-purple-200">
{row.angle}
</span>
</td>
<td className="py-2.5 px-3 text-center font-medium text-gray-800">
{row.creatives_count}
</td>
<td className="py-2.5 px-3 text-center">
{row.fatigued_count > 0 ? (
<span className="rounded-full bg-rose-50 px-2 py-0.5 font-semibold text-rose-700 border border-rose-200">
{row.fatigued_count}
</span>
) : (
<span className="text-gray-400">0</span>
)}
</td>
<td className="py-2.5 px-3 text-right text-gray-600">
{row.impressions.toLocaleString('es-ES')}
</td>
<td className="py-2.5 px-3 text-right font-bold text-gray-900">
{row.ctr}%
</td>
<td className="py-2.5 px-3 text-right text-gray-700">
{row.cpa !== null ? `${row.cpa}€` : '—'}
</td>
<td className="py-2.5 px-3 text-right font-semibold text-gray-900">
{row.spend}€
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Surface>
</div>
</div>
)}
@@ -1214,6 +1537,91 @@ export default function AgencyCreativesView({
</div>
) : null}
{/* MODAL: RECHAZAR CREATIVIDAD CON FEEDBACK PARA CLAUDE (#3) */}
{rejectModalCreative ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs p-4">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl space-y-4">
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
Rechazar Creatividad y Entrenar a Claude
</h3>
<button
onClick={() => setRejectModalCreative(null)}
className="text-gray-400 hover:text-gray-600"
>
✕
</button>
</div>
<div className="rounded-lg bg-red-50/60 p-2.5 border border-red-200 text-xs">
<span className="text-red-500 block text-[10px] font-semibold uppercase">Pieza a rechazar:</span>
<span className="font-bold text-gray-900">
{rejectModalCreative.hook || rejectModalCreative.headline || rejectModalCreative.id}
</span>
{rejectModalCreative.angle ? (
<span className="block text-[11px] text-gray-600 mt-0.5">Ángulo: {rejectModalCreative.angle}</span>
) : null}
</div>
<div className="space-y-3 text-xs">
<div>
<label className="block font-semibold text-gray-700 mb-1">
Motivo del rechazo / Qué debe corregir Claude
</label>
<textarea
rows={3}
placeholder="Ej: El tono suena demasiado corporativo, el hook es muy largo o la foto parece de oficina genérica..."
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
className="w-full rounded-lg border border-gray-300 p-2.5 focus:border-red-500"
/>
<span className="text-[10px] text-gray-400 block mt-1">
Este feedback se guarda en <code>approvals</code> y se inyecta automáticamente en los próximos prompts de Claude para este cliente.
</span>
</div>
<label className="flex items-start gap-2.5 rounded-xl border border-brand-200 bg-brand-50/50 p-3 cursor-pointer">
<input
type="checkbox"
checked={rejectAutoRegenerate}
onChange={(e) => setRejectAutoRegenerate(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<div>
<span className="font-bold text-gray-900 block">
Pedir 1 nueva variante a Claude ahora aplicando esta corrección
</span>
<span className="text-[11px] text-gray-600">
Dispara WF3 inmediatamente con tu nota para generar una alternativa corregida y maquetarla en Canva.
</span>
</div>
</label>
</div>
<div className="flex items-center justify-end gap-2 pt-3 border-t border-gray-100">
<button
onClick={() => setRejectModalCreative(null)}
className="rounded-xl border border-gray-200 px-4 py-2 text-xs font-semibold text-gray-600 hover:bg-gray-50"
>
Cancelar
</button>
<button
onClick={() => void handleConfirmReject()}
disabled={isRejecting || !rejectComment.trim()}
className="rounded-xl bg-red-600 px-4 py-2 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
>
{isRejecting
? 'Procesando...'
: rejectAutoRegenerate
? 'Rechazar y Regenerar con Claude'
: 'Confirmar Rechazo'}
</button>
</div>
</div>
</div>
) : null}
{/* MODAL / DRAWER DE HISTORIAL Y EVENTOS */}
{detailCreative ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs p-4">

View File

@@ -126,9 +126,11 @@ export default function ClientCreativeStylePanel({
'foto-con-franja'?: string;
tipografico?: string;
'dato-grande'?: string;
stories_9_16?: string;
default?: string;
}>({});
const [campaignRows, setCampaignRows] = useState<CampaignRow[]>([]);
const [detectedCampaigns, setDetectedCampaigns] = useState<string[]>([]);
const [style, setStyle] = useState<ClientCreativeStyle>(() =>
createEmptyCreativeStyle(clientName, 'bps')
@@ -151,6 +153,7 @@ export default function ClientCreativeStylePanel({
setDriveFolderId(data.drive_folder_id || '');
setDefaultLandingUrl(data.default_landing_url || '');
setCanvaTemplates(data.canva_templates || {});
setDetectedCampaigns(Array.isArray(data.detected_campaigns) ? data.detected_campaigns : []);
if (data.style) {
setStyle(data.style);
}
@@ -1713,12 +1716,12 @@ export default function ClientCreativeStylePanel({
{/* Plantillas de Canva */}
<div className="space-y-3 pt-2 border-t border-gray-200">
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
Plantillas de Marca en Canva (Brand Template IDs para WF5)
Plantillas de Marca en Canva (Brand Template IDs para WF5 · Feed 4:5 y Stories 9:16)
</h4>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
Layout: foto-con-franja
4:5 · foto-con-franja
</label>
<input
type="text"
@@ -1735,7 +1738,7 @@ export default function ClientCreativeStylePanel({
</div>
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
Layout: tipografico
4:5 · tipografico
</label>
<input
type="text"
@@ -1752,7 +1755,7 @@ export default function ClientCreativeStylePanel({
</div>
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
Layout: dato-grande
4:5 · dato-grande
</label>
<input
type="text"
@@ -1767,9 +1770,26 @@ export default function ClientCreativeStylePanel({
className="w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-brand-700 mb-1">
9:16 · Stories / Reels
</label>
<input
type="text"
value={canvaTemplates.stories_9_16 || ''}
onChange={(e) =>
setCanvaTemplates({
...canvaTemplates,
stories_9_16: e.target.value.trim(),
})
}
placeholder="EAH... (Opcional 9:16)"
className="w-full rounded-lg border border-brand-300 bg-brand-50/30 px-2.5 py-1.5 text-xs font-mono"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
Plantilla por defecto (fallback)
Fallback por defecto
</label>
<input
type="text"
@@ -1851,7 +1871,7 @@ export default function ClientCreativeStylePanel({
{/* Mapeo de Campañas (Audiencia, Producto y Landing) */}
<div className="space-y-3 pt-3 border-t border-gray-200">
<div className="flex items-center justify-between">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
Diccionario de Campañas de Meta Ads (Audiencia, Producto y Landing URL)
@@ -1860,24 +1880,61 @@ export default function ClientCreativeStylePanel({
Se inyecta automáticamente en el <code>brief</code> cuando una creatividad de esa campaña entra en fatiga o pide relevo.
</p>
</div>
<button
type="button"
onClick={() =>
setCampaignRows([
...campaignRows,
{
campaign_name: '',
audience: 'b2c',
product: '',
landing_url: defaultLandingUrl || '',
},
])
}
className="inline-flex items-center gap-1 rounded-lg bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-700 hover:bg-brand-100"
>
<Plus className="w-3.5 h-3.5" />
Añadir campaña
</button>
<div className="flex items-center gap-2">
{detectedCampaigns.length > 0 && (
<button
type="button"
onClick={() => {
const existingNames = new Set(
campaignRows.map((r) => r.campaign_name.trim().toLowerCase())
);
const newRows: CampaignRow[] = [];
for (const cName of detectedCampaigns) {
if (!existingNames.has(cName.trim().toLowerCase())) {
const isB2B = /fundae|empresa|in-company|b2b|equipo|sector/i.test(
cName
);
newRows.push({
campaign_name: cName,
audience: isB2B ? 'b2b' : 'b2c',
product: isB2B
? 'Programas de inglés para empresas (FUNDAE)'
: 'Cursos de inglés para adultos',
landing_url: defaultLandingUrl || '',
});
}
}
if (newRows.length > 0) {
setCampaignRows([...campaignRows, ...newRows]);
} else {
alert('Todas las campañas detectadas en Meta ya están en la tabla.');
}
}}
className="inline-flex items-center gap-1 rounded-lg border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-xs font-semibold text-indigo-700 hover:bg-indigo-100"
>
<Sparkles className="w-3.5 h-3.5" />
Detectar campañas de Meta ({detectedCampaigns.length})
</button>
)}
<button
type="button"
onClick={() =>
setCampaignRows([
...campaignRows,
{
campaign_name: '',
audience: 'b2c',
product: '',
landing_url: defaultLandingUrl || '',
},
])
}
className="inline-flex items-center gap-1 rounded-lg bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-700 hover:bg-brand-100"
>
<Plus className="w-3.5 h-3.5" />
Añadir campaña
</button>
</div>
</div>
<div className="space-y-2">

View File

@@ -1,5 +1,5 @@
import { createClient } from '@supabase/supabase-js';
import { dispatchN8nWebhook } from './creatives-webhooks';
import { dispatchN8nWebhook, dispatchN8nWebhookAsync } from './creatives-webhooks';
import { normalizeRole } from './creatives-visibility';
export interface ActionActor {
@@ -163,7 +163,12 @@ export async function rejectCreative(
creativeId: string,
tenantId: string,
actor: ActionActor,
comment: string
comment: string,
options?: {
channel?: 'app' | 'slack';
autoRegenerate?: boolean;
variantsRequested?: number;
}
) {
if (!comment || !comment.trim()) {
throw new Error('El comentario es obligatorio al rechazar una creatividad');
@@ -207,7 +212,7 @@ export async function rejectCreative(
actor_role: normRole,
decision: 'rechazada',
comment: comment.trim(),
channel: 'app',
channel: options?.channel || 'app',
});
// Registrar en creative_events
@@ -218,7 +223,11 @@ export async function rejectCreative(
actor: actor.userId ? `user:${actor.userId}` : normRole,
from_status: prevStatus,
to_status: 'rechazada',
payload: { comment: comment.trim() },
payload: {
comment: comment.trim(),
channel: options?.channel || 'app',
auto_regenerate: Boolean(options?.autoRegenerate),
},
visibility: 'agency',
});
@@ -231,6 +240,72 @@ export async function rejectCreative(
comment: comment.trim(),
});
// Si se solicita auto-regeneración, crear generation_request aplicando el feedback y lanzar WF3
if (options?.autoRegenerate) {
const { data: tenant } = await adminClient
.from('tenants')
.select('slug, campaign_audience, campaign_products, default_landing_url')
.eq('id', tenantId)
.maybeSingle();
const avoidAngles = creative.angle ? [creative.angle] : [];
const sourceId = creative.parent_id || creative.id;
const campaignName = creative.campaign_name || null;
const audMap = (tenant?.campaign_audience || {}) as Record<string, string>;
const prodMap = (tenant?.campaign_products || {}) as Record<string, any>;
const audience =
campaignName && audMap[campaignName]
? String(audMap[campaignName]).toLowerCase()
: 'b2c';
const prodEntry = campaignName ? prodMap[campaignName] : null;
const product =
typeof prodEntry === 'string'
? prodEntry
: prodEntry?.product || null;
const landingUrl =
prodEntry?.landing_url || creative.landing_url || tenant?.default_landing_url || null;
const briefPayload = {
client_id: tenantId,
tenant_id: tenantId,
client_slug: tenant?.slug || 'bps',
reason: 'manual',
source_creative_id: sourceId,
format: creative.format || 'estatico',
aspect_ratio: '4:5',
variants_requested: options?.variantsRequested || 1,
avoid_angles: avoidAngles,
campaign_name: campaignName,
adset_id: creative.adset_id || null,
audience,
product,
landing_url: landingUrl,
language: 'es',
notes: `Corrección obligatoria tras rechazo (${normRole}): "${comment.trim()}". Creatividad rechazada previa tenía hook: "${creative.hook || ''}" y ángulo: "${creative.angle || ''}".`,
};
const { data: genReq } = await adminClient
.from('generation_requests')
.insert({
tenant_id: tenantId,
reason: 'manual',
source_creative_id: sourceId,
brief: briefPayload,
status: 'pendiente',
})
.select('*')
.single();
if (genReq) {
dispatchN8nWebhookAsync('generation_request.created', {
generation_request: {
...genReq,
client_id: genReq.tenant_id,
},
});
}
}
return updated;
}
@@ -288,3 +363,128 @@ export async function retireCreative(
return updated;
}
/**
* 5. Re-componer creatividad en Canva (WF5) sin regenerar imagen en Freepik
* Actualiza hook/angle/support si se pasan, reutiliza meta.raw_image_url y dispara WF5
*/
export async function recomposeCreativeInCanva(
creativeId: string,
tenantId: string,
actor: ActionActor,
overrides?: {
hook?: string | null;
angle?: string | null;
support?: string | null;
layout?: string | null;
}
) {
const adminClient = getAdminClient();
const { data: creative, error: getErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', creativeId)
.eq('tenant_id', tenantId)
.single();
if (getErr || !creative) {
throw new Error('Creatividad no encontrada');
}
const existingMeta =
creative.meta && typeof creative.meta === 'object' ? { ...creative.meta } : {};
const rawImageUrl = (existingMeta as any).raw_image_url || creative.asset_url;
if (!rawImageUrl) {
throw new Error('Esta creatividad no tiene imagen base para componer en Canva');
}
(existingMeta as any).raw_image_url = rawImageUrl;
if (overrides?.support !== undefined) {
(existingMeta as any).support = overrides.support;
}
if (overrides?.layout) {
(existingMeta as any).layout = overrides.layout;
}
const updates: Record<string, any> = {
asset_final: false,
meta: existingMeta,
updated_at: new Date().toISOString(),
};
if (overrides?.hook !== undefined) {
updates.hook = overrides.hook;
}
if (overrides?.angle !== undefined) {
updates.angle = overrides.angle;
}
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update(updates)
.eq('id', creativeId)
.select('*')
.single();
if (updateErr || !updated) {
throw new Error(updateErr?.message || 'Error al actualizar creatividad para re-composición');
}
const { data: tenant } = await adminClient
.from('tenants')
.select('id, name, slug, drive_folder_id, canva_templates')
.eq('id', tenantId)
.maybeSingle();
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
let canvaTemplates = tenant?.canva_templates || {};
if (
tenant?.slug === 'bps' &&
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
const clientInfo = {
id: tenant?.id || tenantId,
name: tenant?.name || null,
slug: tenant?.slug || 'bps',
drive_folder_id: tenant?.drive_folder_id || null,
canva_templates: canvaTemplates,
};
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: creativeId,
event_type: 'recompose_requested',
actor: actor.userId ? `user:${actor.userId}` : 'agency',
from_status: creative.status,
to_status: updated.status,
payload: {
hook: updated.hook,
support: (existingMeta as any).support || null,
raw_image_url: rawImageUrl,
},
visibility: 'agency',
});
// Disparar WF5 pasando la imagen limpia original (raw_image_url) como asset_url para Canva
dispatchN8nWebhookAsync('creative.created', {
creative: {
...updated,
client_id: updated.tenant_id,
asset_url: rawImageUrl,
meta: existingMeta,
client: clientInfo,
},
});
return updated;
}