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

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