From 46492c3873b3c79a485086d129514401a553f0f5 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 13 Oct 2026 13:17:36 +0200 Subject: [PATCH] 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 --- scripts/provision_agency_admins.ts | 136 +++++++ scripts/seed_blue_parrot_pipeline.ts | 2 +- sql/approval_status_unify.sql | 57 +++ sql/creative_pipeline.sql | 16 +- sql/creative_pipeline_schedule.sql | 6 +- sql/creatives_module_migration.sql | 2 +- sql/generation_requests_copy_v2.sql | 8 + .../api/creative-pipeline/publish/route.ts | 15 +- src/app/api/creative-pipeline/route.ts | 127 +++++-- src/app/api/creatives/[id]/action/route.ts | 53 ++- src/app/api/creatives/upload/route.ts | 2 +- src/app/api/integrations/creatives/route.ts | 21 +- src/components/AgencyCreativesView.tsx | 349 ++++++++++++++++- src/components/ClientCreativeStylePanel.tsx | 2 +- src/components/ClientCreativesView.tsx | 4 +- src/components/CreativePipelineBoard.tsx | 148 ++++---- src/components/CreativesPipeline.tsx | 82 ++-- src/lib/approval-status.ts | 163 ++++++++ src/lib/creative-style.ts | 15 +- src/lib/creatives-regen.ts | 287 ++++++++++++++ src/lib/creatives-types.ts | 2 + src/lib/creatives-webhooks.ts | 3 + src/lib/creatives-workflow.ts | 350 ++++++++++++++++++ src/lib/meta-ad-copy.ts | 350 ++++++++++++++++++ src/lib/pipeline-calendar.ts | 10 +- src/lib/pipeline-publish-worker.ts | 8 +- 26 files changed, 2014 insertions(+), 204 deletions(-) create mode 100644 scripts/provision_agency_admins.ts create mode 100644 sql/approval_status_unify.sql create mode 100644 sql/generation_requests_copy_v2.sql create mode 100644 src/lib/approval-status.ts create mode 100644 src/lib/creatives-regen.ts create mode 100644 src/lib/meta-ad-copy.ts diff --git a/scripts/provision_agency_admins.ts b/scripts/provision_agency_admins.ts new file mode 100644 index 0000000..eacc23d --- /dev/null +++ b/scripts/provision_agency_admins.ts @@ -0,0 +1,136 @@ +/** + * Provision agency admin accounts (role=admin, tenant=4Ventures Central). + * npx tsx scripts/provision_agency_admins.ts + * + * Writes credentials to .ops/agency-admins-credentials.txt (gitignored). + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +import { writeFileSync, mkdirSync } from 'fs'; +import { createClient } from '@supabase/supabase-js'; +import { randomBytes } from 'crypto'; + +config({ path: resolve(process.cwd(), '.env.local') }); + +const HUB_TENANT = '0b3c5fc0-6901-4caf-b26a-b6a0201d8541'; +const COMPANY = '4Ventures'; +const SLUG = '4ventures-hub'; + +const EMAILS = [ + 'marcos@4ventures.es', + 'belen@4ventures.es', + 'rebecca@4ventures.es', + 'victor@4ventures.es', +] as const; + +function tempPassword(local: string): string { + const tag = local.split('@')[0].replace(/[^a-z]/gi, '').slice(0, 8) || 'User'; + const cap = tag.charAt(0).toUpperCase() + tag.slice(1).toLowerCase(); + return `4V${cap}-${randomBytes(4).toString('hex')}!9`; +} + +async function findUser( + admin: ReturnType, + email: string +) { + for (let page = 1; page <= 40; page++) { + const { data, error } = await admin.auth.admin.listUsers({ + page, + perPage: 200, + }); + if (error) throw error; + const hit = data.users.find( + (u) => (u.email || '').toLowerCase() === email.toLowerCase() + ); + if (hit) return hit; + if (data.users.length < 200) break; + } + return null; +} + +async function main() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); + const key = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + if (!url || !key) { + throw new Error( + 'Faltan NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY en .env.local' + ); + } + + const admin = createClient(url, key, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const lines: string[] = [ + '# Agency admin access — app.4ventures.es/login', + `# generated ${new Date().toISOString()}`, + `# tenant ${COMPANY} (${HUB_TENANT}) role=admin`, + '', + ]; + + for (const email of EMAILS) { + const password = tempPassword(email); + const app_metadata = { + role: 'admin', + tenant_id: HUB_TENANT, + company: COMPANY, + slug: SLUG, + }; + const user_metadata = { + role: 'admin', + tenant_id: HUB_TENANT, + company: COMPANY, + slug: SLUG, + }; + + const found = await findUser(admin, email); + if (found) { + const { error } = await admin.auth.admin.updateUserById(found.id, { + password, + email_confirm: true, + app_metadata: { ...(found.app_metadata || {}), ...app_metadata }, + user_metadata: { ...(found.user_metadata || {}), ...user_metadata }, + }); + if (error) throw new Error(`${email}: ${error.message}`); + console.log('updated', email, found.id); + lines.push(`email=${email}`); + lines.push(`password=${password}`); + lines.push(`user_id=${found.id}`); + lines.push(`action=updated`); + lines.push(''); + } else { + const { data, error } = await admin.auth.admin.createUser({ + email, + password, + email_confirm: true, + app_metadata, + user_metadata, + }); + if (error) throw new Error(`${email}: ${error.message}`); + console.log('created', email, data.user?.id); + lines.push(`email=${email}`); + lines.push(`password=${password}`); + lines.push(`user_id=${data.user?.id || ''}`); + lines.push(`action=created`); + lines.push(''); + } + } + + mkdirSync(resolve(process.cwd(), '.ops'), { recursive: true }); + const out = resolve(process.cwd(), '.ops/agency-admins-credentials.txt'); + writeFileSync(out, lines.join('\n'), { mode: 0o600 }); + console.log('---'); + console.log('credentials', out); + console.log('login https://app.4ventures.es/login'); + for (const email of EMAILS) { + const pass = lines.find( + (l, i) => lines[i - 1] === `email=${email}` && l.startsWith('password=') + ); + console.log(email, pass?.replace('password=', '') || '?'); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/seed_blue_parrot_pipeline.ts b/scripts/seed_blue_parrot_pipeline.ts index b8bc31d..3cf95d1 100644 --- a/scripts/seed_blue_parrot_pipeline.ts +++ b/scripts/seed_blue_parrot_pipeline.ts @@ -48,7 +48,7 @@ async function main() { if (!dir || !existsSync(dir)) { throw new Error('Pasa --dir /ruta/a/la/carpeta/descomprimida (el ZIP de las 33 piezas)'); } - const status = arg('status', 'revision_interna')!; + const status = arg('status', 'revision_agencia')!; const metaPath = resolve(__dirname, 'blue-parrot-pipeline-seed.json'); const items: SeedItem[] = JSON.parse(readFileSync(metaPath, 'utf-8')); diff --git a/sql/approval_status_unify.sql b/sql/approval_status_unify.sql new file mode 100644 index 0000000..773924c --- /dev/null +++ b/sql/approval_status_unify.sql @@ -0,0 +1,57 @@ +-- Unifica estados de aprobación Ads (creatives) + RRSS (creative_pipeline). +-- Flujo: borrador → revision_agencia → pendiente_aprobacion → aprobada|rechazada → publicada +-- Idempotente. + +-- ─── creative_pipeline (RRSS) ─────────────────────────────────────────────── + +alter table public.creative_pipeline drop constraint if exists creative_pipeline_status_check; + +update public.creative_pipeline set status = 'borrador' where status in ('propuesta', 'en_diseno'); +-- revision_interna se usaba como "el cliente decide" → pendiente_aprobacion +update public.creative_pipeline set status = 'pendiente_aprobacion' where status = 'revision_interna'; +update public.creative_pipeline set status = 'aprobada' where status = 'aprobado'; +update public.creative_pipeline set status = 'rechazada' where status = 'rechazado'; +update public.creative_pipeline set status = 'publicada' where status = 'publicado'; + +alter table public.creative_pipeline + alter column status set default 'borrador'; + +alter table public.creative_pipeline + add constraint creative_pipeline_status_check + check (status in ( + 'borrador', + 'revision_agencia', + 'pendiente_aprobacion', + 'aprobada', + 'rechazada', + 'publicada' + )); + +comment on column public.creative_pipeline.status is + 'Flujo unificado: borrador → revision_agencia → pendiente_aprobacion → aprobada|rechazada → publicada'; + +-- ─── creatives (Ads) ──────────────────────────────────────────────────────── + +alter table public.creatives drop constraint if exists creatives_status_check; + +-- Legacy EN → ES canónico +update public.creatives set status = 'borrador' where status = 'draft'; +update public.creatives set status = 'pendiente_aprobacion' where status = 'in_review'; +update public.creatives set status = 'aprobada' where status = 'approved'; +update public.creatives set status = 'rechazada' where status = 'rejected'; + +alter table public.creatives + add constraint creatives_status_check + check (status in ( + 'borrador', + 'revision_agencia', + 'pendiente_aprobacion', + 'aprobada', + 'rechazada', + 'publicada', + 'fatigada', + 'retirada' + )); + +comment on column public.creatives.status is + 'Flujo unificado + extras Ads (fatigada, retirada). revision_agencia = validación interna antes de enviar al cliente.'; diff --git a/sql/creative_pipeline.sql b/sql/creative_pipeline.sql index ef14336..cd65eb1 100644 --- a/sql/creative_pipeline.sql +++ b/sql/creative_pipeline.sql @@ -1,9 +1,7 @@ --- Creative pipeline: producción interna de creatividades (Blue Parrot y resto --- de clientes) separada de `creatives` (que es el repositorio de anuncios --- Meta/Google ya aprobados). Aquí vive el flujo editorial completo: --- Propuesta -> En diseño -> Revisión interna -> Aprobado / Rechazado -> Publicado --- Visible también para el cliente en /dashboard/panel-cliente (RLS por tenant). --- Idempotente. +-- Creative pipeline: producción RRSS separada de `creatives` (Ads Meta). +-- Flujo unificado (ver sql/approval_status_unify.sql): +-- borrador → revision_agencia → pendiente_aprobacion → aprobada|rechazada → publicada +-- Cliente solo ve desde pendiente_aprobacion. Idempotente. create table if not exists public.creative_pipeline ( id uuid primary key default gen_random_uuid(), @@ -19,10 +17,10 @@ create table if not exists public.creative_pipeline ( -- mes/calendario al que pertenece la pieza (agrupación en el board) month text, -- 'YYYY-MM' - status text not null default 'propuesta' + status text not null default 'borrador' check (status in ( - 'propuesta', 'en_diseno', 'revision_interna', - 'aprobado', 'rechazado', 'publicado' + 'borrador', 'revision_agencia', 'pendiente_aprobacion', + 'aprobada', 'rechazada', 'publicada' )), -- portada (todas las piezas tienen una) diff --git a/sql/creative_pipeline_schedule.sql b/sql/creative_pipeline_schedule.sql index 6365e7c..20bd0ae 100644 --- a/sql/creative_pipeline_schedule.sql +++ b/sql/creative_pipeline_schedule.sql @@ -1,6 +1,6 @@ -- Agency-only scheduling for editorial creativities. --- aprobado + scheduled_at → cron publishes IG/FB; status becomes publicado. --- Idempotent. +-- aprobada + scheduled_at → cron publishes IG/FB; status becomes publicada. +-- Idempotent. (Estados canónicos: sql/approval_status_unify.sql) alter table public.creative_pipeline add column if not exists scheduled_at timestamptz; @@ -38,7 +38,7 @@ alter table public.creative_pipeline create index if not exists creative_pipeline_due_publish_idx on public.creative_pipeline (tenant_id, scheduled_at) - where status = 'aprobado' and publish_status in ('idle', 'queued', 'failed'); + where status = 'aprobada' and publish_status in ('idle', 'queued', 'failed'); create index if not exists creative_pipeline_scheduled_at_idx on public.creative_pipeline (tenant_id, scheduled_at) diff --git a/sql/creatives_module_migration.sql b/sql/creatives_module_migration.sql index d251cc7..8a6233c 100644 --- a/sql/creatives_module_migration.sql +++ b/sql/creatives_module_migration.sql @@ -70,7 +70,7 @@ alter table public.creatives add constraint creatives_meta_ad_id_key unique (met -- Enforce check constraints from brief alter table public.creatives add constraint creatives_status_check - check (status in ('borrador','pendiente_aprobacion','aprobada','rechazada','publicada','fatigada','retirada','draft','in_review','approved','rejected')); + check (status in ('borrador','revision_agencia','pendiente_aprobacion','aprobada','rechazada','publicada','fatigada','retirada')); alter table public.creatives add constraint creatives_format_check check (format in ('estatico','carrusel','video','image','carousel','reel','story','rsa','pmax','pmax_landscape','pmax_square','pmax_portrait','pmax_vertical','pmax_logo','youtube','other')); diff --git a/sql/generation_requests_copy_v2.sql b/sql/generation_requests_copy_v2.sql new file mode 100644 index 0000000..e881d5b --- /dev/null +++ b/sql/generation_requests_copy_v2.sql @@ -0,0 +1,8 @@ +-- Opcional: permite reason 'copy_v2' además de fatiga/calendario/nueva_campana/manual. +-- Idempotente. Si no se aplica, el código usa reason='manual' + brief.copy_v2. + +alter table public.generation_requests drop constraint if exists generation_requests_reason_check; + +alter table public.generation_requests + add constraint generation_requests_reason_check + check (reason in ('fatiga', 'calendario', 'nueva_campana', 'manual', 'copy_v2')); diff --git a/src/app/api/creative-pipeline/publish/route.ts b/src/app/api/creative-pipeline/publish/route.ts index 081c86e..78e6146 100644 --- a/src/app/api/creative-pipeline/publish/route.ts +++ b/src/app/api/creative-pipeline/publish/route.ts @@ -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, diff --git a/src/app/api/creative-pipeline/route.ts b/src/app/api/creative-pipeline/route.ts index 65c6be5..05d84da 100644 --- a/src/app/api/creative-pipeline/route.ts +++ b/src/app/api/creative-pipeline/route.ts @@ -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(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 = { - 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( + 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>( 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(); } diff --git a/src/app/api/creatives/[id]/action/route.ts b/src/app/api/creatives/[id]/action/route.ts index 8882f7a..b4ebc35 100644 --- a/src/app/api/creatives/[id]/action/route.ts +++ b/src/app/api/creatives/[id]/action/route.ts @@ -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 }); } diff --git a/src/app/api/creatives/upload/route.ts b/src/app/api/creatives/upload/route.ts index 075fcb4..dc24925 100644 --- a/src/app/api/creatives/upload/route.ts +++ b/src/app/api/creatives/upload/route.ts @@ -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, diff --git a/src/app/api/integrations/creatives/route.ts b/src/app/api/integrations/creatives/route.ts index 3acb6e3..f87b69e 100644 --- a/src/app/api/integrations/creatives/route.ts +++ b/src/app/api/integrations/creatives/route.ts @@ -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, diff --git a/src/components/AgencyCreativesView.tsx b/src/components/AgencyCreativesView.tsx index 4307881..93571f5 100644 --- a/src/components/AgencyCreativesView.tsx +++ b/src/components/AgencyCreativesView.tsx @@ -29,6 +29,8 @@ import { } from 'lucide-react'; import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui'; import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel'; +import { readMetaAdCopyBundle } from '@/lib/meta-ad-copy'; +import { REGEN_PRESETS } from '@/lib/creatives-regen'; export type CreativeFormat = 'estatico' | 'carrusel' | 'video'; @@ -43,6 +45,7 @@ export type CreativeItem = { hook?: string | null; primary_text?: string | null; headline?: string | null; + description?: string | null; cta?: string | null; asset_url?: string | null; thumbnail_url?: string | null; @@ -94,6 +97,7 @@ export type GenerationRequest = { const KANBAN_COLUMNS = [ { id: 'borrador', label: 'Borrador', color: 'border-amber-400 bg-amber-50/30' }, + { id: 'revision_agencia', label: 'Revisión agencia', color: 'border-violet-400 bg-violet-50/30' }, { id: 'pendiente_aprobacion', label: 'Pendiente Cliente', color: 'border-blue-400 bg-blue-50/30' }, { id: 'aprobada', label: 'Aprobada', color: 'border-emerald-400 bg-emerald-50/30' }, { id: 'publicada', label: 'Publicada Meta', color: 'border-indigo-400 bg-indigo-50/30' }, @@ -149,6 +153,18 @@ export default function AgencyCreativesView({ const [rejectAutoRegenerate, setRejectAutoRegenerate] = useState(true); const [isRejecting, setIsRejecting] = useState(false); + // Regeneración personalizada por capas + const [keepImage, setKeepImage] = useState(true); + const [keepHook, setKeepHook] = useState(true); + const [keepSupport, setKeepSupport] = useState(true); + const [hookOverride, setHookOverride] = useState(''); + const [supportOverride, setSupportOverride] = useState(''); + const [keepPrimaries, setKeepPrimaries] = useState([]); + const [keepHeadlines, setKeepHeadlines] = useState([]); + const [keepDescription, setKeepDescription] = useState(false); + const [copyV2Notes, setCopyV2Notes] = useState(''); + const [copyV2Busy, setCopyV2Busy] = useState(false); + const handleSaveInline = async (creativeId: string, alsoRecompose = false) => { setSavingInline(true); try { @@ -296,7 +312,23 @@ export default function AgencyCreativesView({ void loadData(); }, [loadData]); - // 2. Acción: Enviar al cliente + // 2a. Validación interna agencia + const handleAgencyReview = async (creative: CreativeItem) => { + try { + const res = await fetch(`/api/creatives/${creative.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'agency_review' }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Error al marcar revisión'); + await loadData(); + } catch (err: any) { + alert(`Error: ${err.message}`); + } + }; + + // 2b. Enviar al cliente (aviso + pendiente_aprobacion) const handleSendToClient = async (creative: CreativeItem) => { if (!creative.asset_final) { alert('Solo se puede enviar al cliente si cuenta con imagen final compuesta (asset_final=true). Por favor sube la imagen final primero.'); @@ -446,24 +478,106 @@ export default function AgencyCreativesView({ // 7. Ver detalle / eventos const handleOpenDetail = async (c: CreativeItem) => { setDetailCreative(c); + setKeepImage(true); + setKeepHook(true); + setKeepSupport(true); + setHookOverride(''); + setSupportOverride(''); + setKeepPrimaries([]); + setKeepHeadlines([]); + setKeepDescription(false); + setCopyV2Notes(''); try { const res = await fetch(`/api/creatives/${c.id}`); if (res.ok) { const data = await res.json(); setDetailEvents(data.events || []); + if (data.creative) setDetailCreative(data.creative); } } catch { setDetailEvents([]); } }; + const toggleKeepIndex = ( + list: number[], + setList: (n: number[]) => void, + index: number + ) => { + setList( + list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort() + ); + }; + + const applyRegenPreset = (presetId: string) => { + if (!detailCreative) return; + const feed = readMetaAdCopyBundle(detailCreative); + const preset = REGEN_PRESETS.find((p) => p.id === presetId); + if (!preset) return; + const sel = preset.apply(feed); + setKeepImage(sel.layers.image); + setKeepHook(sel.layers.hook); + setKeepSupport(sel.layers.support); + setKeepPrimaries(sel.feed_keep.primary_indices); + setKeepHeadlines(sel.feed_keep.headline_indices); + setKeepDescription(sel.feed_keep.description); + setHookOverride(''); + setSupportOverride(''); + }; + + const handlePartialV2 = async () => { + if (!detailCreative) return; + setCopyV2Busy(true); + try { + const res = await fetch(`/api/creatives/${detailCreative.id}/action`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'partial_v2', + layers: { + image: keepImage, + hook: keepHook, + support: keepSupport, + }, + hook_override: !keepHook ? hookOverride.trim() || null : hookOverride.trim() || null, + support_override: !keepSupport + ? supportOverride.trim() || null + : supportOverride.trim() || null, + keep: { + primary_indices: keepPrimaries, + headline_indices: keepHeadlines, + description: keepDescription, + }, + notes: copyV2Notes.trim() || null, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || 'Error al pedir regeneración'); + await loadData(); + const lines = (data.plan?.summary || data.summary || []) as string[]; + const mode = data.mode || ''; + alert( + [ + mode === 'canva_only' + ? 'Re-montaje Canva enviado (misma imagen, nuevo texto en pieza).' + : 'Regeneración personalizada pedida.', + ...(lines.length ? lines : []), + ].join('\n') + ); + } catch (err: any) { + alert(err.message); + } finally { + setCopyV2Busy(false); + } + }; + // Columnas Kanban const kanbanGroups = useMemo(() => { const groups: Record = {}; for (const col of KANBAN_COLUMNS) groups[col.id] = []; for (const c of creatives) { - let st = c.status; + let st = c.status as string; if (st === 'draft') st = 'borrador'; if (st === 'in_review') st = 'pendiente_aprobacion'; if (st === 'approved') st = 'aprobada'; @@ -899,14 +1013,24 @@ export default function AgencyCreativesView({ {c.asset_final ? 'Cambiar final' : 'Subir final'} - {/* Botón Enviar al Cliente (solo si está en borrador o pendiente) */} + {/* Validación agencia + enviar al cliente */} {col.id === 'borrador' ? ( + + ) : null} + {col.id === 'borrador' || col.id === 'revision_agencia' ? ( + ))} + +
+ + + {!keepHook ? ( + setHookOverride(e.target.value)} + placeholder="Nuevo hook manual (vacío = lo escribe la IA)" + className="ml-6 rounded-lg border border-gray-200 px-2 py-1.5 text-xs" + /> + ) : null} + + {!keepSupport ? ( + setSupportOverride(e.target.value)} + placeholder="Nuevo support manual (vacío = IA)" + className="ml-6 rounded-lg border border-gray-200 px-2 py-1.5 text-xs" + /> + ) : null} +
+
+

+ Ahora en pieza +

+

+ Hook: {copy.hook || detailCreative.hook || '—'} +

+

+ Support: {copy.support || '—'} +

+
+ +
+
+ + Copy Meta (acompaña) · ≤120 / ≤30 · emojis + +

+ Marca variantes a conservar. Sin marcar = v2 de ese slot. +

+
+
+ + Primary text ({primaryList.length}/5 · ≤120) + +
    + {primaryList.map((p, i) => ( +
  • + +
  • + ))} + {!primaryList.length ? ( +
  • Sin primary aún
  • + ) : null} +
+
+
+ + Headlines ({headList.length}/5 · ≤30) + +
    + {headList.map((h, i) => ( +
  • + +
  • + ))} + {!headList.length ? ( +
  • Sin headlines aún
  • + ) : null} +
+
+
+ +
+
+