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