Unify Ads/RRSS approval flow and add personalized Meta creative regen.

Agency validates first then client; Meta copy is 5+5+1 with on-image vs feed layers, emojis on feed, and partial v2 by image/hook/support/copy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-13 13:17:36 +02:00
parent 4bc9ed7820
commit 46492c3873
26 changed files with 2014 additions and 204 deletions

163
src/lib/approval-status.ts Normal file
View File

@@ -0,0 +1,163 @@
/**
* Estados de aprobación unificados para Ads (`creatives`) y RRSS (`creative_pipeline`).
*
* Flujo:
* borrador → revision_agencia → pendiente_aprobacion → aprobada | rechazada → publicada
*
* - borrador / revision_agencia: solo agencia
* - pendiente_aprobacion: cliente puede aprobar/rechazar (+ aviso al enviar)
* - aprobada: lista para publicar / programar
* - Ads extras: fatigada, retirada
*/
export const APPROVAL_STATUSES = [
'borrador',
'revision_agencia',
'pendiente_aprobacion',
'aprobada',
'rechazada',
'publicada',
'fatigada',
'retirada',
] as const;
export type ApprovalStatus = (typeof APPROVAL_STATUSES)[number];
/** Estados del flujo editorial compartido (sin extras Ads). */
export const SHARED_APPROVAL_STATUSES = [
'borrador',
'revision_agencia',
'pendiente_aprobacion',
'aprobada',
'rechazada',
'publicada',
] as const;
export type SharedApprovalStatus = (typeof SHARED_APPROVAL_STATUSES)[number];
const SHARED_SET = new Set<string>(SHARED_APPROVAL_STATUSES);
const ALL_SET = new Set<string>(APPROVAL_STATUSES);
/** Legacy RRSS → canónico */
const LEGACY_TO_CANONICAL: Record<string, ApprovalStatus> = {
propuesta: 'borrador',
en_diseno: 'borrador',
// Históricamente el cliente decidía aquí → era "pendiente cliente"
revision_interna: 'pendiente_aprobacion',
aprobado: 'aprobada',
rechazado: 'rechazada',
publicado: 'publicada',
// Legacy Ads EN
draft: 'borrador',
in_review: 'pendiente_aprobacion',
approved: 'aprobada',
rejected: 'rechazada',
published: 'publicada',
};
export const APPROVAL_LABEL: Record<ApprovalStatus, string> = {
borrador: 'Borrador',
revision_agencia: 'Revisión agencia',
pendiente_aprobacion: 'Pendiente cliente',
aprobada: 'Aprobada',
rechazada: 'Rechazada',
publicada: 'Publicada',
fatigada: 'Fatigada',
retirada: 'Retirada',
};
export const APPROVAL_COLOR: Record<ApprovalStatus, string> = {
borrador: 'bg-gray-100 text-gray-700 border-gray-200',
revision_agencia: 'bg-violet-100 text-violet-800 border-violet-200',
pendiente_aprobacion: 'bg-amber-100 text-amber-800 border-amber-200',
aprobada: 'bg-emerald-100 text-emerald-700 border-emerald-200',
rechazada: 'bg-red-100 text-red-700 border-red-200',
publicada: 'bg-purple-100 text-purple-700 border-purple-200',
fatigada: 'bg-rose-100 text-rose-700 border-rose-200',
retirada: 'bg-gray-100 text-gray-500 border-gray-200',
};
/** Columnas kanban / filtros agencia (RRSS + Ads base). */
export const AGENCY_STATUS_ORDER: SharedApprovalStatus[] = [
'borrador',
'revision_agencia',
'pendiente_aprobacion',
'aprobada',
'rechazada',
'publicada',
];
/** Lo que el cliente puede ver. */
export const CLIENT_VISIBLE_STATUSES: ApprovalStatus[] = [
'pendiente_aprobacion',
'aprobada',
'rechazada',
'publicada',
'fatigada',
'retirada',
];
/** Cliente solo decide desde pendiente_aprobacion. */
export const CLIENT_ALLOWED_TRANSITIONS: Record<
string,
SharedApprovalStatus[]
> = {
pendiente_aprobacion: ['aprobada', 'rechazada'],
};
export function isApprovalStatus(v: unknown): v is ApprovalStatus {
return typeof v === 'string' && ALL_SET.has(v);
}
export function isSharedApprovalStatus(v: unknown): v is SharedApprovalStatus {
return typeof v === 'string' && SHARED_SET.has(v);
}
/** Normaliza legacy o canónico → canónico. Desconocido → borrador. */
export function normalizeApprovalStatus(
raw: string | null | undefined
): ApprovalStatus {
if (!raw) return 'borrador';
if (ALL_SET.has(raw)) return raw as ApprovalStatus;
const mapped = LEGACY_TO_CANONICAL[raw];
if (mapped) return mapped;
return 'borrador';
}
export function approvalLabel(status: string): string {
const n = normalizeApprovalStatus(status);
return APPROVAL_LABEL[n] || status;
}
export function approvalColor(status: string): string {
const n = normalizeApprovalStatus(status);
return APPROVAL_COLOR[n];
}
export function clientCanDecide(status: string): boolean {
return normalizeApprovalStatus(status) === 'pendiente_aprobacion';
}
export function isAgencyOnlyStatus(status: string): boolean {
const n = normalizeApprovalStatus(status);
return n === 'borrador' || n === 'revision_agencia';
}
/** Agencia puede enviar al cliente desde borrador o revisión agencia. */
export function canSendToClient(status: string): boolean {
const n = normalizeApprovalStatus(status);
return n === 'borrador' || n === 'revision_agencia';
}
/**
* Acepta status de escritura: canónico o legacy (normalizado).
* Devuelve canónico o null si no es válido para el dominio shared/pipeline.
*/
export function parseSharedStatusWrite(
raw: unknown
): SharedApprovalStatus | null {
if (typeof raw !== 'string') return null;
const n = normalizeApprovalStatus(raw);
if (SHARED_SET.has(n)) return n as SharedApprovalStatus;
return null;
}

View File

@@ -1,3 +1,5 @@
import { metaAdCopySkillRules } from '@/lib/meta-ad-copy';
export type CreativeB2BPersona = {
name: string;
role: string;
@@ -166,7 +168,7 @@ export const BPS_DEFAULT_CREATIVE_STYLE: ClientCreativeStyle = {
language_and_pronoun:
'Español de España, tú o usted según el público: tú en B2C, tú en B2B cuando hablamos a la persona, usted nunca.',
copy_structure_rules:
'Frases cortas. Un dato concreto vale más que un adjetivo. En primary_text: 2-4 frases, máx. 300 caracteres; la frase 1 amplía el hook, frases 2-3 dan un beneficio o dato concreto, frase final cierra con el CTA sin enlaces ni hashtags.',
'Dos capas: (1) EN IMAGEN — hook + support para Canva; (2) ACOMPAÑA EN META — 5 primary_text ≤120, 5 headline ≤30, 1 description (info + bullets + CTA). No mezclar hook de imagen con primary_text del anuncio. Frases cortas; sin enlaces ni hashtags en primary/headline.',
english_terms_policy:
'Inglés en el copy: solo nombres propios de exámenes (Cambridge, Aptis, IELTS) o términos sin traducción natural (in-company). Nada de "boost", "unlock", "level up".',
power_words: [
@@ -238,7 +240,7 @@ export const BPS_DEFAULT_CREATIVE_STYLE: ClientCreativeStyle = {
],
competitors_never_mention: ['Twenix', 'Preply', 'Italki', 'Open English', 'What\'s Up!'],
emoji_policy:
'Prohibidos los emojis en creatividades de imagen. En copy (primary_text), como mucho uno al inicio y solo en B2C (nunca en B2B).',
'Sin emojis en texto DENTRO de la imagen (hook/support). En copy que ACOMPAÑA (primary_text, headline, description) los emojis son OBLIGATORIOS en cada variante.',
testimonials_policy:
'Prohibidos testimonios inventados o con nombre y cargo de personas que no existen. Si no hay un caso real aprobado, no se usa testimonio.',
},
@@ -322,7 +324,8 @@ export function createEmptyCreativeStyle(clientName: string, slug: string): Clie
tone: 'Profesional, directo y cercano.',
posture: '',
language_and_pronoun: 'Español de España, tratamiento de tú.',
copy_structure_rules: 'Frases cortas y directas. Un dato concreto vale más que un adjetivo.',
copy_structure_rules:
'EN IMAGEN: hook + support. ACOMPAÑA META: 5 primary ≤120, 5 headline ≤30, 1 description. No mezclar capas.',
english_terms_policy: '',
power_words: [],
allowed_ctas: ['Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'],
@@ -341,7 +344,8 @@ export function createEmptyCreativeStyle(clientName: string, slug: string): Clie
'Deformar o recrear el logo mediante IA.',
],
competitors_never_mention: [],
emoji_policy: 'Sin emojis en imagen. En copy, máximo uno al inicio si aplica.',
emoji_policy:
'Sin emojis en imagen (hook/support). Emojis obligatorios en primary/headline/description que acompañan.',
testimonials_policy: 'Solo casos reales aprobados por el cliente; nunca testimonios ficticios.',
},
visual: {
@@ -646,5 +650,8 @@ export function compileClientSkillMarkdown(
lines.push('');
}
lines.push(metaAdCopySkillRules());
lines.push('');
return lines.join('\n');
}

287
src/lib/creatives-regen.ts Normal file
View File

@@ -0,0 +1,287 @@
/**
* Regeneración personalizada por capas de una creatividad Ads.
*
* Capas independientes (conservar / regenerar):
* 1. Imagen (Freepik / asset base)
* 2. Hook — título DENTRO de la imagen
* 3. Support — subtítulo DENTRO de la imagen
* 4. Copy Meta que ACOMPAÑA (primary / headline / description, con keep parcial)
*
* Rutas de ejecución:
* - Solo hook/support (y hay texto nuevo o AI) + misma imagen → Canva WF5 (recompose)
* - Cualquier cambio de imagen o copy feed AI → generation_request (WF3+) con plan
*/
import {
feedCopyRegenPlan,
normalizeKeepSelection,
type FeedCopyKeepSelection,
type MetaFeedCopy,
} from '@/lib/meta-ad-copy';
export type LayerKeep = {
/** Conservar asset / raw_image actual */
image: boolean;
/** Conservar hook (título en imagen) */
hook: boolean;
/** Conservar support (subtítulo en imagen) */
support: boolean;
};
export type CreativeRegenSelection = {
layers: LayerKeep;
/** Si regeneras hook/support, puedes fijar el texto nuevo (si vacío → IA) */
hook_override?: string | null;
support_override?: string | null;
/** Keep parcial del copy que acompaña (Meta) */
feed_keep: FeedCopyKeepSelection;
};
export type CreativeRegenPlan = {
selection: CreativeRegenSelection;
regenerate: {
image: boolean;
hook: boolean;
support: boolean;
feed: boolean;
};
/** Texto on-image que debe quedar tras la operación */
next_hook: string | null;
next_support: string | null;
feed_plan: ReturnType<typeof feedCopyRegenPlan>;
/** Solo re-montar Canva, sin Freepik ni Claude copy */
canva_only: boolean;
/** Hace falta generation_request (Claude / Freepik) */
needs_generation_request: boolean;
/** Resumen humano */
summary: string[];
};
export function defaultLayerKeep(): LayerKeep {
return { image: true, hook: true, support: true };
}
export function defaultFeedKeepAll(feed: MetaFeedCopy): FeedCopyKeepSelection {
return {
primary_indices: feed.primary_texts
.map((t, i) => (t ? i : -1))
.filter((i) => i >= 0),
headline_indices: feed.headlines
.map((t, i) => (t ? i : -1))
.filter((i) => i >= 0),
description: Boolean(feed.description),
};
}
/** Presets de UX */
export const REGEN_PRESETS: {
id: string;
label: string;
hint: string;
apply: (feed: MetaFeedCopy) => CreativeRegenSelection;
}[] = [
{
id: 'image_only',
label: 'Solo nueva imagen',
hint: 'Me gustan todos los textos; cambio la foto',
apply: (feed) => ({
layers: { image: false, hook: true, support: true },
feed_keep: defaultFeedKeepAll(feed),
}),
},
{
id: 'hook_only',
label: 'Solo título en imagen (hook)',
hint: 'La foto y el subtítulo están bien',
apply: (feed) => ({
layers: { image: true, hook: false, support: true },
feed_keep: defaultFeedKeepAll(feed),
}),
},
{
id: 'support_only',
label: 'Solo subtítulo en imagen (support)',
hint: 'La foto y el título están bien',
apply: (feed) => ({
layers: { image: true, hook: true, support: false },
feed_keep: defaultFeedKeepAll(feed),
}),
},
{
id: 'on_image_text',
label: 'Título + subtítulo en imagen',
hint: 'Misma foto; nuevo texto en Canva',
apply: (feed) => ({
layers: { image: true, hook: false, support: false },
feed_keep: defaultFeedKeepAll(feed),
}),
},
{
id: 'feed_only',
label: 'Solo copy Meta (el que acompaña)',
hint: 'Imagen y textos de pieza iguales; regeneras primary/headline/desc',
apply: () => ({
layers: { image: true, hook: true, support: true },
feed_keep: {
primary_indices: [],
headline_indices: [],
description: false,
},
}),
},
];
export function buildCreativeRegenPlan(
current: {
hook?: string | null;
support?: string | null;
feed: MetaFeedCopy;
},
selection: CreativeRegenSelection
): CreativeRegenPlan {
const layers = {
image: selection.layers?.image !== false,
hook: selection.layers?.hook !== false,
support: selection.layers?.support !== false,
};
const regenerate = {
image: !layers.image,
hook: !layers.hook,
support: !layers.support,
feed: false,
};
const feed_keep = normalizeKeepSelection(selection.feed_keep);
const feed_plan = feedCopyRegenPlan(current.feed, feed_keep);
regenerate.feed = feed_plan.needsRegen;
const hookOverride = (selection.hook_override || '').trim();
const supportOverride = (selection.support_override || '').trim();
let next_hook: string | null = (current.hook || '').trim() || null;
let next_support: string | null = (current.support || '').trim() || null;
if (regenerate.hook) {
next_hook = hookOverride || null; // null = IA
} else if (hookOverride) {
next_hook = hookOverride; // edición manual sin “regenerar”
}
if (regenerate.support) {
next_support = supportOverride || null;
} else if (supportOverride) {
next_support = supportOverride;
}
const canva_only =
!regenerate.image &&
!regenerate.feed &&
(regenerate.hook ||
regenerate.support ||
Boolean(hookOverride) ||
Boolean(supportOverride)) &&
// Canva-only solo si ya tenemos textos finales (override o keep)
(layers.hook || Boolean(hookOverride)) &&
(layers.support || Boolean(supportOverride) || next_support !== null);
// Si regeneramos hook/support sin override → hace falta IA (generation_request)
const onImageNeedsAi =
(regenerate.hook && !hookOverride) || (regenerate.support && !supportOverride);
const needs_generation_request =
regenerate.image || regenerate.feed || onImageNeedsAi;
// canva_only real: cambios de texto en imagen con overrides (o keep) y misma imagen, sin AI
const canva_only_strict =
!needs_generation_request &&
layers.image &&
(Boolean(hookOverride) ||
Boolean(supportOverride) ||
regenerate.hook === false ||
regenerate.support === false) &&
(Boolean(hookOverride) || Boolean(supportOverride));
const summary: string[] = [];
if (regenerate.image) summary.push('Nueva imagen (Freepik)');
else summary.push('Conservar imagen');
if (regenerate.hook) {
summary.push(
hookOverride
? `Nuevo hook (manual): “${hookOverride.slice(0, 40)}”`
: 'Nuevo hook (IA)'
);
} else summary.push('Conservar hook');
if (regenerate.support) {
summary.push(
supportOverride
? `Nuevo support (manual): “${supportOverride.slice(0, 40)}”`
: 'Nuevo support (IA)'
);
} else summary.push('Conservar support');
if (regenerate.feed) {
summary.push(
`Copy Meta: regenerar ${feed_plan.regenerate_primary_slots.length} primary, ${feed_plan.regenerate_headline_slots.length} headline${feed_plan.regenerate_description ? ', description' : ''}`
);
} else summary.push('Conservar copy Meta');
if (
!regenerate.image &&
!regenerate.hook &&
!regenerate.support &&
!regenerate.feed &&
!hookOverride &&
!supportOverride
) {
throw new Error(
'No hay nada que cambiar. Desmarca alguna capa o edita hook/support.'
);
}
return {
selection: {
layers,
hook_override: hookOverride || null,
support_override: supportOverride || null,
feed_keep,
},
regenerate,
next_hook,
next_support,
feed_plan,
canva_only: canva_only_strict,
needs_generation_request,
summary,
};
}
export function buildPartialRegenBriefNotes(plan: CreativeRegenPlan): string {
return [
'REGENERACIÓN PERSONALIZADA POR CAPAS',
plan.summary.map((s) => `- ${s}`).join('\n'),
'',
plan.regenerate.image
? 'IMAGEN: generar nueva (Freepik). Luego Canva con hook/support indicados.'
: 'IMAGEN: reutilizar raw_image_url / asset actual. No llamar Freepik.',
plan.regenerate.hook && !plan.selection.hook_override
? 'HOOK: inventar nuevo (sin emojis, máx ~8 palabras).'
: `HOOK locked/manual: "${plan.next_hook || ''}"`,
plan.regenerate.support && !plan.selection.support_override
? 'SUPPORT: inventar nuevo (sin emojis).'
: `SUPPORT locked/manual: "${plan.next_support || ''}"`,
'',
plan.regenerate.feed
? [
'COPY META (acompaña) — regeneración parcial:',
`Primary locked: ${plan.feed_plan.locked_primaries.map((p) => `[${p.index}]`).join(', ') || 'ninguno'}`,
`Primary regen slots: ${plan.feed_plan.regenerate_primary_slots.join(', ') || 'ninguno'}`,
`Headline locked: ${plan.feed_plan.locked_headlines.map((p) => `[${p.index}]`).join(', ') || 'ninguno'}`,
`Headline regen slots: ${plan.feed_plan.regenerate_headline_slots.join(', ') || 'ninguno'}`,
plan.feed_plan.regenerate_description
? 'Description: REGENERAR (+ emojis)'
: 'Description: LOCKED',
'Emojis obligatorios en copy Meta. Sin emojis en hook/support.',
].join('\n')
: 'COPY META: conservar primary_texts / headlines / description tal cual.',
].join('\n');
}

View File

@@ -23,12 +23,14 @@ export type CreativeFormat =
export type CreativeStatus =
| 'borrador'
| 'revision_agencia'
| 'pendiente_aprobacion'
| 'aprobada'
| 'rechazada'
| 'publicada'
| 'fatigada'
| 'retirada'
/** @deprecated legacy — normalizar con normalizeApprovalStatus */
| 'draft'
| 'in_review'
| 'approved'

View File

@@ -4,6 +4,9 @@ export type CreativeWebhookEvent =
| 'creative.approved'
| 'creative.rejected'
| 'creative.sent_to_client'
| 'pipeline.sent_to_client'
| 'pipeline.approved'
| 'pipeline.rejected'
| 'generation_request.created'
| 'creative.created';

View File

@@ -1,6 +1,19 @@
import { createClient } from '@supabase/supabase-js';
import { dispatchN8nWebhook, dispatchN8nWebhookAsync } from './creatives-webhooks';
import { normalizeRole } from './creatives-visibility';
import {
buildCopyV2BriefNotes,
feedCopyRegenPlan,
normalizeKeepSelection,
readMetaAdCopy,
readOnImageCopy,
type FeedCopyKeepSelection,
} from './meta-ad-copy';
import {
buildCreativeRegenPlan,
buildPartialRegenBriefNotes,
type CreativeRegenSelection,
} from './creatives-regen';
export interface ActionActor {
role: 'agency' | 'client' | string;
@@ -13,10 +26,67 @@ export function getAdminClient() {
return createClient(supabaseUrl, serviceRoleKey);
}
/**
* Marca revisión agencia OK (antes de enviar al cliente).
*/
export async function markCreativeAgencyReview(
creativeId: string,
tenantId: string,
actor: ActionActor
) {
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 prev = String(creative.status || '');
if (prev !== 'borrador' && prev !== 'draft' && prev !== 'revision_agencia') {
throw new Error('Solo se puede marcar revisión agencia desde borrador');
}
const prevStatus = creative.status;
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update({
status: 'revision_agencia',
visibility: 'agency',
updated_at: new Date().toISOString(),
})
.eq('id', creativeId)
.select('*')
.single();
if (updateErr) {
throw new Error(updateErr.message);
}
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: creativeId,
event_type: 'status_change',
actor: actor.userId ? `user:${actor.userId}` : 'agency',
from_status: prevStatus,
to_status: 'revision_agencia',
payload: { action: 'agency_review' },
visibility: 'agency',
});
return updated;
}
/**
* 1. Enviar al cliente
* Cambia visibility a 'client' y status a 'pendiente_aprobacion'
* Dispara webhook 'creative.sent_to_client'
* Solo desde borrador o revision_agencia (tras validación interna).
*/
export async function sendCreativeToClient(creativeId: string, tenantId: string, actor: ActionActor) {
const adminClient = getAdminClient();
@@ -36,6 +106,17 @@ export async function sendCreativeToClient(creativeId: string, tenantId: string,
throw new Error('Solo se puede enviar al cliente si cuenta con imagen final compuesta (asset_final=true)');
}
const prevNorm = String(creative.status || '');
if (
prevNorm !== 'borrador' &&
prevNorm !== 'draft' &&
prevNorm !== 'revision_agencia'
) {
throw new Error(
'Solo se puede enviar al cliente desde borrador o revisión agencia'
);
}
const prevStatus = creative.status;
const { data: updated, error: updateErr } = await adminClient
@@ -364,6 +445,275 @@ export async function retireCreative(
return updated;
}
/**
* Pedir v2 solo del copy que ACOMPAÑA (Meta feed).
* Atajo de requestCreativePartialV2.
*/
export async function requestFeedCopyV2(
creativeId: string,
tenantId: string,
actor: ActionActor,
keepRaw: Partial<FeedCopyKeepSelection>,
extraNotes?: string | null
) {
return requestCreativePartialV2(
creativeId,
tenantId,
actor,
{
layers: { image: true, hook: true, support: true },
feed_keep: normalizeKeepSelection(keepRaw),
},
extraNotes
);
}
/**
* Regeneración personalizada: imagen / hook / support / copy Meta (cualquier mix).
* - Solo texto en imagen con override manual → Canva WF5
* - Resto → generation_request con plan de capas
*/
export async function requestCreativePartialV2(
creativeId: string,
tenantId: string,
actor: ActionActor,
selection: CreativeRegenSelection,
extraNotes?: string | null
) {
const adminClient = getAdminClient();
const normRole = normalizeRole(actor.role);
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 onImage = readOnImageCopy(creative);
const feed = readMetaAdCopy(creative);
const plan = buildCreativeRegenPlan(
{
hook: onImage.hook || creative.hook,
support: onImage.support,
feed,
},
selection
);
// Camino rápido: solo re-montar Canva con textos conocidos
if (plan.canva_only) {
const recomposed = await recomposeCreativeInCanva(
creativeId,
tenantId,
actor,
{
hook: plan.next_hook,
support: plan.next_support,
}
);
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: creativeId,
event_type: 'partial_regen_canva',
actor: actor.userId ? `user:${actor.userId}` : normRole,
from_status: creative.status,
to_status: creative.status,
payload: { summary: plan.summary, plan: plan.regenerate },
visibility: 'agency',
});
return {
creative: recomposed,
generation_request: null,
plan,
mode: 'canva_only' as const,
};
}
const existingMeta =
creative.meta && typeof creative.meta === 'object'
? { ...(creative.meta as Record<string, unknown>) }
: {};
const prevCopy =
existingMeta.copy && typeof existingMeta.copy === 'object'
? { ...(existingMeta.copy as Record<string, unknown>) }
: {};
const nextMeta = {
...existingMeta,
support: plan.next_support ?? existingMeta.support,
copy: {
...prevCopy,
primary_texts: feed.primary_texts.filter(Boolean),
headlines: feed.headlines.filter(Boolean),
description: feed.description,
hook: plan.next_hook ?? onImage.hook,
support: plan.next_support ?? onImage.support,
partial_v2: {
requested_at: new Date().toISOString(),
regenerate: plan.regenerate,
feed: plan.feed_plan,
next_hook: plan.next_hook,
next_support: plan.next_support,
summary: plan.summary,
},
},
};
const patch: Record<string, unknown> = {
meta: nextMeta,
updated_at: new Date().toISOString(),
};
if (plan.next_hook != null) patch.hook = plan.next_hook;
if (!plan.regenerate.feed) {
patch.primary_text = creative.primary_text;
patch.headline = creative.headline;
patch.description = creative.description;
} else {
patch.primary_text =
plan.feed_plan.locked_primaries.find((p) => p.index === 0)?.text ||
creative.primary_text;
patch.headline =
plan.feed_plan.locked_headlines.find((p) => p.index === 0)?.text ||
creative.headline;
if (plan.feed_plan.keep.description) {
patch.description = feed.description || creative.description;
}
}
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update(patch)
.eq('id', creativeId)
.select('*')
.single();
if (updateErr) {
throw new Error(updateErr.message);
}
if (!plan.needs_generation_request) {
return {
creative: updated,
generation_request: null,
plan,
mode: 'meta_only' as const,
};
}
const { data: tenant } = await adminClient
.from('tenants')
.select('slug, campaign_audience, campaign_products, default_landing_url')
.eq('id', tenantId)
.maybeSingle();
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 rawImageUrl =
(existingMeta as { raw_image_url?: string }).raw_image_url ||
creative.asset_url;
const notes = [
buildPartialRegenBriefNotes(plan),
plan.regenerate.feed ? buildCopyV2BriefNotes(plan.feed_plan) : '',
extraNotes?.trim() ? `Notas agencia: ${extraNotes.trim()}` : '',
]
.filter(Boolean)
.join('\n\n');
const briefPayload = {
client_id: tenantId,
tenant_id: tenantId,
client_slug: tenant?.slug || 'bps',
reason: 'manual',
source_creative_id: creative.id,
format: creative.format || 'estatico',
aspect_ratio: '4:5',
variants_requested: 1,
avoid_angles: plan.regenerate.image && creative.angle ? [creative.angle] : [],
campaign_name: campaignName,
adset_id: creative.adset_id || null,
audience,
product,
landing_url: landingUrl,
language: 'es',
copy_only: !plan.regenerate.image && plan.regenerate.feed,
keep_asset: !plan.regenerate.image,
keep_on_image: !plan.regenerate.hook && !plan.regenerate.support,
raw_image_url: !plan.regenerate.image ? rawImageUrl : null,
locked_hook: plan.regenerate.hook ? null : plan.next_hook,
locked_support: plan.regenerate.support ? null : plan.next_support,
partial_regen: plan,
copy_v2: plan.regenerate.feed ? plan.feed_plan : null,
notes,
};
const { data: genReq, error: genErr } = await adminClient
.from('generation_requests')
.insert({
tenant_id: tenantId,
reason: 'manual',
source_creative_id: creative.id,
brief: briefPayload,
status: 'pendiente',
})
.select('*')
.single();
if (genErr) {
throw new Error(genErr.message);
}
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: creativeId,
event_type: 'partial_regen_requested',
actor: actor.userId ? `user:${actor.userId}` : normRole,
from_status: creative.status,
to_status: creative.status,
payload: {
summary: plan.summary,
regenerate: plan.regenerate,
generation_request_id: genReq?.id,
},
visibility: 'agency',
});
if (genReq) {
dispatchN8nWebhookAsync('generation_request.created', {
generation_request: {
...genReq,
client_id: genReq.tenant_id,
},
});
}
return {
creative: updated,
generation_request: genReq,
plan,
mode: 'generation_request' as const,
};
}
/**
* 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

350
src/lib/meta-ad-copy.ts Normal file
View File

@@ -0,0 +1,350 @@
/**
* Dos capas de texto en una creatividad Ads Meta:
*
* 1) DENTRO de la pieza (Canva / imagen) — sin límites 120/30; sin emojis
* - hook + support
*
* 2) QUE ACOMPAÑA la pieza (Meta feed) — SÍ límites + SIEMPRE emojis
* - 5 primary_text ≤ 120
* - 5 headline ≤ 30
* - 1 description (info + bullets + CTA + emojis)
*
* Selección parcial (v2): conservar variantes que gustan y regenerar solo el resto.
*/
export const META_PRIMARY_MAX = 120;
export const META_HEADLINE_MAX = 30;
export const META_PRIMARY_COUNT = 5;
export const META_HEADLINE_COUNT = 5;
/** Texto montado en la creatividad (imagen). */
export type OnImageCopy = {
hook: string;
support: string;
};
/** Copy del anuncio en Meta (junto a la creatividad). */
export type MetaFeedCopy = {
primary_texts: string[];
headlines: string[];
description: string;
};
export type MetaAdCopyBundle = OnImageCopy & MetaFeedCopy;
/** @deprecated prefer MetaFeedCopy */
export type MetaAdCopy = MetaFeedCopy;
export type FeedCopyKeepSelection = {
/** Índices 0–4 de primary_texts a conservar */
primary_indices: number[];
/** Índices 0–4 de headlines a conservar */
headline_indices: number[];
/** true = conservar description actual */
description: boolean;
};
export function clip(s: string, max: number): string {
const t = s.trim();
if (t.length <= max) return t;
return t.slice(0, max).trim();
}
function asStringList(v: unknown, count: number, maxLen: number): string[] {
const raw = Array.isArray(v)
? v
: typeof v === 'string' && v.trim()
? [v]
: [];
const out: string[] = [];
for (const item of raw) {
if (typeof item !== 'string') continue;
const c = clip(item, maxLen);
if (c) out.push(c);
if (out.length >= count) break;
}
while (out.length < count) out.push('');
return out.slice(0, count);
}
function uniqIndices(raw: unknown, maxExclusive: number): number[] {
if (!Array.isArray(raw)) return [];
const set = new Set<number>();
for (const x of raw) {
const n = Number(x);
if (Number.isInteger(n) && n >= 0 && n < maxExclusive) set.add(n);
}
return [...set].sort((a, b) => a - b);
}
export function readOnImageCopy(row: {
hook?: string | null;
meta?: Record<string, unknown> | null;
}): OnImageCopy {
const meta = (row.meta || {}) as Record<string, unknown>;
const support =
typeof meta.support === 'string'
? meta.support
: typeof (meta.copy as { support?: string } | undefined)?.support ===
'string'
? String((meta.copy as { support?: string }).support)
: '';
return {
hook: (row.hook || '').trim(),
support: support.trim(),
};
}
export function readMetaAdCopy(row: {
primary_text?: string | null;
headline?: string | null;
description?: string | null;
hook?: string | null;
meta?: Record<string, unknown> | null;
}): MetaFeedCopy {
const meta = (row.meta || {}) as Record<string, unknown>;
const nested =
meta.copy && typeof meta.copy === 'object'
? (meta.copy as Record<string, unknown>)
: meta;
const primary_texts = asStringList(
nested.primary_texts ?? meta.primary_texts ?? row.primary_text,
META_PRIMARY_COUNT,
META_PRIMARY_MAX
);
const headlines = asStringList(
nested.headlines ?? meta.headlines ?? row.headline,
META_HEADLINE_COUNT,
META_HEADLINE_MAX
);
const description = clip(
String(
nested.description ?? meta.description_long ?? row.description ?? ''
),
5000
);
if (!primary_texts[0] && row.primary_text) {
primary_texts[0] = clip(row.primary_text, META_PRIMARY_MAX);
}
if (!headlines[0] && row.headline) {
headlines[0] = clip(row.headline, META_HEADLINE_MAX);
}
return { primary_texts, headlines, description };
}
export function readMetaAdCopyBundle(row: {
primary_text?: string | null;
headline?: string | null;
description?: string | null;
hook?: string | null;
meta?: Record<string, unknown> | null;
}): MetaAdCopyBundle {
return { ...readOnImageCopy(row), ...readMetaAdCopy(row) };
}
export function normalizeKeepSelection(
raw: Partial<FeedCopyKeepSelection> | null | undefined
): FeedCopyKeepSelection {
return {
primary_indices: uniqIndices(raw?.primary_indices, META_PRIMARY_COUNT),
headline_indices: uniqIndices(raw?.headline_indices, META_HEADLINE_COUNT),
description: Boolean(raw?.description),
};
}
/** Qué hay que regenerar a partir de lo que se conserva. */
export function feedCopyRegenPlan(
feed: MetaFeedCopy,
keep: FeedCopyKeepSelection
): {
keep: FeedCopyKeepSelection;
locked_primaries: { index: number; text: string }[];
locked_headlines: { index: number; text: string }[];
locked_description: string | null;
regenerate_primary_slots: number[];
regenerate_headline_slots: number[];
regenerate_description: boolean;
needsRegen: boolean;
} {
const k = normalizeKeepSelection(keep);
const locked_primaries = k.primary_indices
.map((index) => ({ index, text: feed.primary_texts[index] || '' }))
.filter((x) => x.text);
const locked_headlines = k.headline_indices
.map((index) => ({ index, text: feed.headlines[index] || '' }))
.filter((x) => x.text);
const regenerate_primary_slots = Array.from(
{ length: META_PRIMARY_COUNT },
(_, i) => i
).filter((i) => !k.primary_indices.includes(i));
const regenerate_headline_slots = Array.from(
{ length: META_HEADLINE_COUNT },
(_, i) => i
).filter((i) => !k.headline_indices.includes(i));
const regenerate_description = !k.description;
const locked_description = k.description ? feed.description || null : null;
const needsRegen =
regenerate_primary_slots.length > 0 ||
regenerate_headline_slots.length > 0 ||
regenerate_description;
return {
keep: k,
locked_primaries,
locked_headlines,
locked_description,
regenerate_primary_slots,
regenerate_headline_slots,
regenerate_description,
needsRegen,
};
}
export function normalizeMetaAdCopyInput(input: {
primary_text?: unknown;
primary_texts?: unknown;
headline?: unknown;
headlines?: unknown;
description?: unknown;
hook?: unknown;
support?: unknown;
cta?: unknown;
meta?: Record<string, unknown> | null;
}): {
primary_text: string | null;
headline: string | null;
description: string | null;
hook: string | null;
meta: Record<string, unknown>;
onImage: OnImageCopy;
feed: MetaFeedCopy;
} {
const primary_texts = asStringList(
input.primary_texts ?? input.primary_text,
META_PRIMARY_COUNT,
META_PRIMARY_MAX
);
const headlines = asStringList(
input.headlines ?? input.headline,
META_HEADLINE_COUNT,
META_HEADLINE_MAX
);
let description = '';
if (typeof input.description === 'string') {
description = input.description.trim();
}
const cta = typeof input.cta === 'string' ? input.cta.trim() : '';
if (description && cta && !description.includes(cta)) {
description = `${description}\n\n→ ${cta}`;
}
const hook = typeof input.hook === 'string' ? input.hook.trim() : '';
const support =
typeof input.support === 'string'
? input.support.trim()
: typeof input.meta?.support === 'string'
? String(input.meta.support).trim()
: '';
const onImage: OnImageCopy = { hook, support };
const feed: MetaFeedCopy = {
primary_texts,
headlines,
description,
};
const meta = { ...(input.meta || {}) };
if (support) meta.support = support;
meta.copy = {
hook: hook || undefined,
support: support || undefined,
primary_texts: primary_texts.filter(Boolean),
headlines: headlines.filter(Boolean),
description,
limits: {
applies_to: 'feed_accompanying_only',
primary_max: META_PRIMARY_MAX,
headline_max: META_HEADLINE_MAX,
primary_count: META_PRIMARY_COUNT,
headline_count: META_HEADLINE_COUNT,
emojis: 'required_on_feed_copy',
},
};
return {
primary_text: primary_texts[0] || null,
headline: headlines[0] || null,
description: description || null,
hook: hook || null,
meta,
onImage,
feed,
};
}
export function metaAdCopySkillRules(): string {
return [
'## Dos capas de texto (no las mezcles)',
'',
'### 1) Texto DENTRO de la creatividad (imagen / Canva)',
'- `hook` + `support` (máx. 2 niveles). Sin límites de 120/30 caracteres.',
'- **Sin emojis** en la imagen.',
'- Va a plantilla Canva. NO es el primary_text ni el headline de Meta.',
'',
'### 2) Texto que ACOMPAÑA la creatividad (anuncio Meta)',
`- **5 primary_text** ≤ ${META_PRIMARY_MAX} caracteres c/u. Variantes distintas.`,
`- **5 headline** ≤ ${META_HEADLINE_MAX} caracteres c/u.`,
'- **1 description**: info completa + bullet points + CTA.',
'- **Emojis OBLIGATORIOS** en primary_text, headline y description (al menos 1 emoji relevante por variante; en description varios repartidos en bullets/cierre).',
'- Sin enlaces ni hashtags en primary/headline.',
'',
'### Regeneración parcial (v2)',
'Si el brief trae `copy_v2` con variantes `locked_*`, **copia esas literales** en los índices indicados y solo inventa nuevas para los slots `regenerate_*`. No cambies hook/support/imagen salvo que el brief lo pida.',
'',
'Devuelve en JSON:',
'```',
'{',
' "hook": "...",',
' "support": "...",',
' "primary_texts": ["...😀","...✨","...💪","...🎯","...🔥"],',
' "headlines": ["...✅","...🚀","...💡","...⭐","...🙌"],',
' "description": "...\\n• ...✨\\n• ...\\n→ CTA 👉",',
' "cta": "..."',
'}',
'```',
].join('\n');
}
/** Notas para el brief de generation_request cuando hay v2 parcial. */
export function buildCopyV2BriefNotes(plan: ReturnType<typeof feedCopyRegenPlan>): string {
const lines = [
'REGENERACIÓN PARCIAL DE COPY QUE ACOMPAÑA (capa Meta feed). No tocar texto en imagen (hook/support) ni asset.',
'Conservar EXACTAMENTE las variantes locked; regenerar solo los slots indicados.',
'Límites solo en copy que acompaña: primary ≤120, headline ≤30. Emojis OBLIGATORIOS en cada variante nueva y en description si se regenera.',
'',
`Primary locked: ${
plan.locked_primaries.length
? plan.locked_primaries.map((p) => `[${p.index}] "${p.text}"`).join(' · ')
: '(ninguno)'
}`,
`Primary a regenerar (índices): ${plan.regenerate_primary_slots.join(', ') || '(ninguno)'}`,
`Headline locked: ${
plan.locked_headlines.length
? plan.locked_headlines.map((p) => `[${p.index}] "${p.text}"`).join(' · ')
: '(ninguno)'
}`,
`Headline a regenerar (índices): ${plan.regenerate_headline_slots.join(', ') || '(ninguno)'}`,
plan.regenerate_description
? 'Description: REGENERAR completa (info + bullets + CTA + emojis).'
: `Description LOCKED: "${plan.locked_description || ''}"`,
];
return lines.join('\n');
}

View File

@@ -45,12 +45,12 @@ export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
}
const st = (it.status || '').toLowerCase();
const ref = (it.external_ref || '').toLowerCase();
if (st === 'publicado' || ref.startsWith('ig-')) {
if (st === 'publicada' || st === 'publicado' || ref.startsWith('ig-')) {
const d = (it.created_at || '').slice(0, 10);
return d || null;
}
// Approved + scheduled pieces also show on the calendar day (live plan).
if (st === 'aprobado' && it.scheduled_at) {
if ((st === 'aprobada' || st === 'aprobado') && it.scheduled_at) {
return it.scheduled_at.slice(0, 10);
}
return null;
@@ -133,7 +133,11 @@ export function mergePipelineIntoPlans(
if (!day) continue;
// Show published + approved-with-schedule on the live calendar.
const st = (it.status || '').toLowerCase();
if (st !== 'publicado' && !(st === 'aprobado' && it.scheduled_at)) {
if (
st !== 'publicada' &&
st !== 'publicado' &&
!((st === 'aprobada' || st === 'aprobado') && it.scheduled_at)
) {
continue;
}
const ig = instagramUrlFromNotes(it.notes);

View File

@@ -68,7 +68,7 @@ export type DuePublishResult = {
* Fetch and publish due items.
* Criteria: scheduled_at <= now, cover present, and
* - publish_status in (queued, failed), OR
* - publish_status=idle AND status=aprobado
* - publish_status=idle AND status=aprobada
*/
export async function processDuePipelinePublishes(opts?: {
limit?: number;
@@ -107,7 +107,7 @@ export async function processDuePipelinePublishes(opts?: {
const q1 =
`${base}/rest/v1/creative_pipeline?publish_status=in.(queued,failed)&${common}`;
const q2 =
`${base}/rest/v1/creative_pipeline?publish_status=eq.idle&status=eq.aprobado&${common}`;
`${base}/rest/v1/creative_pipeline?publish_status=eq.idle&status=eq.aprobada&${common}`;
const [r1, r2] = await Promise.all([
fetch(q1, { headers, cache: 'no-store' }),
@@ -165,7 +165,7 @@ export async function processDuePipelinePublishes(opts?: {
publish_status: 'publishing',
publish_attempts: attempts,
publish_error: null,
status: 'aprobado',
status: 'aprobada',
});
if (!lock.ok) {
results.push({
@@ -263,7 +263,7 @@ export async function processDuePipelinePublishes(opts?: {
publish_status: 'published',
publish_error: null,
published_at: new Date().toISOString(),
status: 'publicado',
status: 'publicada',
meta_ig_media_id: igMediaId,
meta_fb_post_id: fbPostId,
caption: cleanCaption || null,