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