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

View File

@@ -208,7 +208,7 @@ export async function POST(req: NextRequest) {
publish_status: 'published',
publish_error: null,
published_at: new Date().toISOString(),
status: 'publicado',
status: 'publicada',
meta_ig_media_id: result.igMediaId || null,
meta_fb_post_id: result.fbPostId || null,
caption: item.caption || null,
@@ -240,9 +240,14 @@ export async function POST(req: NextRequest) {
publish_error: null,
scheduled_at: new Date().toISOString(),
status:
item.status === 'aprobado' || item.status === 'publicado'
? item.status
: 'aprobado',
item.status === 'aprobada' ||
item.status === 'publicada' ||
item.status === 'aprobado' ||
item.status === 'publicado'
? item.status === 'publicado' || item.status === 'publicada'
? 'publicada'
: 'aprobada'
: 'aprobada',
});
// Publish IG then FB, persisting each id immediately (avoids IG duplicates on retry).
@@ -307,7 +312,7 @@ export async function POST(req: NextRequest) {
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,
notes,

View File

@@ -22,30 +22,21 @@ import {
} from '@/lib/calendar-documents';
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
import { sanitizePublishCaption } from '@/lib/publish-caption';
import {
CLIENT_ALLOWED_TRANSITIONS,
CLIENT_VISIBLE_STATUSES,
canSendToClient,
normalizeApprovalStatus,
parseSharedStatusWrite,
type SharedApprovalStatus,
} from '@/lib/approval-status';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
const SELECT =
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error,calendar_piece_id';
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
const ALL_STATUS = [
'propuesta',
'en_diseno',
'revision_interna',
'aprobado',
'rechazado',
'publicado',
] as const;
type Status = (typeof ALL_STATUS)[number];
const STATUS_SET = new Set<string>(ALL_STATUS);
// El cliente solo puede mover una pieza que esté en revisión interna,
// y únicamente hacia aprobar o rechazar. El resto de transiciones (incluida
// "publicado") son cosa del equipo de la agencia.
const CLIENT_ALLOWED_TRANSITIONS: Record<string, Status[]> = {
revision_interna: ['aprobado', 'rechazado'],
};
function str(v: unknown, max = 2000): string | null {
if (typeof v !== 'string') return null;
const t = v.trim();
@@ -96,7 +87,10 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
}
return NextResponse.json({
items: redactScheduleForClient(await r.json(), isAgency),
items: redactScheduleForClient(
filterPipelineForRole(await r.json(), isAgency),
isAgency
),
isAgency,
});
}
@@ -115,11 +109,33 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ items: [], isAgency, warning: error.message });
}
return NextResponse.json({
items: redactScheduleForClient(data ?? [], isAgency),
items: redactScheduleForClient(
filterPipelineForRole(data ?? [], isAgency),
isAgency
),
isAgency,
});
}
function filterPipelineForRole<T extends { status?: string }>(
rows: T[],
isAgency: boolean
): T[] {
if (isAgency) {
return rows.map((row) => ({
...row,
status: normalizeApprovalStatus(row.status),
}));
}
const visible = new Set(CLIENT_VISIBLE_STATUSES);
return rows
.map((row) => ({
...row,
status: normalizeApprovalStatus(row.status),
}))
.filter((row) => visible.has(row.status as (typeof CLIENT_VISIBLE_STATUSES)[number]));
}
function redactScheduleForClient<T extends Record<string, unknown>>(
rows: T[],
isAgency: boolean
@@ -234,7 +250,7 @@ export async function POST(req: NextRequest) {
month: month || null,
caption: caption || null,
notes: notes || null,
status: 'propuesta',
status: 'borrador',
created_by: user.id,
calendar_piece_id: resolvedCalendarId || null,
};
@@ -332,33 +348,77 @@ export async function PATCH(req: NextRequest) {
updated_at: new Date().toISOString(),
};
if (typeof body.status === 'string' && STATUS_SET.has(body.status)) {
const next = body.status as Status;
const currentNorm = normalizeApprovalStatus(current);
const parsedNext =
'status' in body ? parseSharedStatusWrite(body.status) : null;
if (parsedNext) {
const next: SharedApprovalStatus = parsedNext;
if (agency) {
clean.status = next;
} else {
const allowed = CLIENT_ALLOWED_TRANSITIONS[current] || [];
const allowed = CLIENT_ALLOWED_TRANSITIONS[currentNorm] || [];
if (!allowed.includes(next)) {
return NextResponse.json(
{ error: `Como cliente solo puedes aprobar o rechazar una pieza en revisión interna` },
{
error:
'Como cliente solo puedes aprobar o rechazar una pieza pendiente de tu validación',
},
{ status: 403 }
);
}
clean.status = next;
}
if (next === 'aprobado') {
if (next === 'aprobada') {
clean.approved_by = user.id;
clean.approved_at = new Date().toISOString();
// If already scheduled by agency, queue for the worker.
if (agency && curRows?.[0]?.scheduled_at) {
clean.publish_status = 'queued';
}
if (!agency) {
dispatchN8nWebhookAsync('pipeline.approved', {
client_id: ctx.tenantId,
tenant_id: ctx.tenantId,
pipeline_id: id,
channel: 'rrss',
});
}
}
if (next === 'rechazado' || next === 'propuesta' || next === 'en_diseno') {
if (next === 'rechazada') {
if (agency) {
clean.publish_status = 'idle';
} else {
dispatchN8nWebhookAsync('pipeline.rejected', {
client_id: ctx.tenantId,
tenant_id: ctx.tenantId,
pipeline_id: id,
channel: 'rrss',
});
}
}
if (next === 'borrador' || next === 'revision_agencia') {
if (agency) {
clean.publish_status = 'idle';
}
}
// Agencia envía al cliente → aviso
if (
agency &&
next === 'pendiente_aprobacion' &&
canSendToClient(currentNorm)
) {
dispatchN8nWebhookAsync('pipeline.sent_to_client', {
client_id: ctx.tenantId,
tenant_id: ctx.tenantId,
pipeline_id: id,
channel: 'rrss',
});
}
} else if ('status' in body && body.status != null) {
return NextResponse.json(
{ error: 'status inválido' },
{ status: 400 }
);
}
// Resto de campos: solo agencia los edita.
@@ -394,10 +454,13 @@ export async function PATCH(req: NextRequest) {
}
clean.scheduled_at = parsed;
if (parsed) {
// Always arm the worker + ensure editorial status is aprobado.
// (Cron only publishes queued/aprobado due items.)
if (current !== 'publicado' && clean.status !== 'publicado') {
clean.status = 'aprobado';
// Cron publica queued + status aprobada. Programar implica ya validada.
const nextStatus =
typeof clean.status === 'string'
? clean.status
: currentNorm;
if (nextStatus !== 'publicada') {
clean.status = 'aprobada';
clean.approved_by = user.id;
clean.approved_at = new Date().toISOString();
}

View File

@@ -4,10 +4,13 @@ import { resolveActiveTenant } from '@/lib/tenant-context';
import { userRole } from '@/lib/agency';
import {
sendCreativeToClient,
markCreativeAgencyReview,
approveCreative,
rejectCreative,
retireCreative,
recomposeCreativeInCanva,
requestFeedCopyV2,
requestCreativePartialV2,
} from '@/lib/creatives-workflow';
export async function POST(
@@ -54,12 +57,16 @@ export async function POST(
);
}
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire' | 'recompose'
const action = body.action; // 'agency_review' | 'send_to_client' | 'approve' | 'reject' | 'retire' | 'recompose' | 'copy_v2'
const actor = { role, userId: user.id };
try {
let result: any;
switch (action) {
case 'agency_review':
result = await markCreativeAgencyReview(id, tenantId, actor);
break;
case 'send_to_client':
result = await sendCreativeToClient(id, tenantId, actor);
break;
@@ -89,6 +96,48 @@ export async function POST(
});
break;
case 'copy_v2': {
const keep = body.keep || {};
result = await requestFeedCopyV2(
id,
tenantId,
actor,
{
primary_indices: keep.primary_indices,
headline_indices: keep.headline_indices,
description: keep.description,
},
body.notes || body.comment || null
);
break;
}
case 'partial_v2': {
const layers = body.layers || {};
const keep = body.keep || body.feed_keep || {};
result = await requestCreativePartialV2(
id,
tenantId,
actor,
{
layers: {
image: layers.image !== false,
hook: layers.hook !== false,
support: layers.support !== false,
},
hook_override: body.hook_override ?? body.hook ?? null,
support_override: body.support_override ?? body.support ?? null,
feed_keep: {
primary_indices: keep.primary_indices,
headline_indices: keep.headline_indices,
description: keep.description,
},
},
body.notes || body.comment || null
);
break;
}
default:
return NextResponse.json(
{ error: `Acción desconocida: ${action}` },
@@ -96,7 +145,7 @@ export async function POST(
);
}
return NextResponse.json({ ok: true, creative: result });
return NextResponse.json({ ok: true, ...((result && result.creative) ? result : { creative: result }) });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400 });
}

View File

@@ -98,7 +98,7 @@ export async function POST(req: NextRequest) {
const row = {
tenant_id: ctx.tenantId,
title: title.slice(0, 300),
status: 'draft',
status: 'borrador',
channel: 'meta_ads',
format: mime.startsWith('video/') ? 'video' : 'image',
created_by: user.id,

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import { normalizeMetaAdCopyInput } from '@/lib/meta-ad-copy';
import { createClient } from '@supabase/supabase-js';
export async function POST(req: NextRequest) {
@@ -50,7 +51,18 @@ export async function POST(req: NextRequest) {
}
const toInsert = items.map((c: any) => {
const meta = { ...(c.meta || {}) };
const copyNorm = normalizeMetaAdCopyInput({
primary_text: c.primary_text,
primary_texts: c.primary_texts,
headline: c.headline,
headlines: c.headlines,
description: c.description,
hook: c.hook,
support: c.support,
cta: c.cta,
meta: c.meta || {},
});
const meta = { ...copyNorm.meta };
if (c.composition_notes) {
meta.composition_notes = c.composition_notes;
}
@@ -64,9 +76,10 @@ export async function POST(req: NextRequest) {
meta_ad_id: c.meta_ad_id || null,
format: c.format || 'estatico',
angle: c.angle || null,
hook: c.hook || null,
primary_text: c.primary_text || null,
headline: c.headline || null,
hook: copyNorm.hook || c.hook || null,
primary_text: copyNorm.primary_text,
headline: copyNorm.headline,
description: copyNorm.description,
cta: c.cta || null,
asset_url: c.asset_url || null,
thumbnail_url: c.thumbnail_url || null,