Add RRSS auto-generation via the Ads Claude/Freepik/Canva pipeline.
Pipeline pieces can be created manually or enqueued with channel=rrss, using brandbook, calendar copy and RRSS history; n8n writes results back to creative_pipeline. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
33
skills/formats/carrusel.md
Normal file
33
skills/formats/carrusel.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Skill de formato · Carrusel RRSS (slides 4:5)
|
||||||
|
|
||||||
|
> Carrusel orgánico: **3–5 slides** en **4:5**. Canal `rrss` → Pipeline (`slide_paths` + cover = slide 1).
|
||||||
|
|
||||||
|
## Entrega por variante
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "ángulo del carrusel",
|
||||||
|
"hook": "titular del slide 1, máx. 8 palabras",
|
||||||
|
"support": "subtítulo slide 1 o null",
|
||||||
|
"caption": "caption del post carrusel completo",
|
||||||
|
"cta": "CTA final (slide último / caption)",
|
||||||
|
"slides": [
|
||||||
|
{
|
||||||
|
"role": "hook",
|
||||||
|
"hook": "texto slide",
|
||||||
|
"support": null,
|
||||||
|
"image_prompt": "prompt EN INGLÉS 4:5 para este slide",
|
||||||
|
"composition_notes": "montaje slide"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"layout": "foto-con-franja | tipografico | dato-grande",
|
||||||
|
"rationale": "por qué esta secuencia"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
- Slide 1 = gancho; medios = valor; último = CTA / guardado.
|
||||||
|
- Misma paleta y tono en todos los slides.
|
||||||
|
- Cada `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 with negative space for headline`.
|
||||||
|
- Si el calendario ya trae estructura de carrusel, respétala.
|
||||||
32
skills/formats/post.md
Normal file
32
skills/formats/post.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Skill de formato · Post RRSS (feed IG/FB 4:5)
|
||||||
|
|
||||||
|
> Se aplica junto a la skill del cliente (brandbook). Produce una pieza **orgánica** para el Pipeline RRSS, no un anuncio Meta Ads.
|
||||||
|
> Diseño base **4:5 (1080×1350)**. Una sola pieza feed (no hace falta landscape Ads).
|
||||||
|
|
||||||
|
## Contexto de entrada
|
||||||
|
|
||||||
|
El brief trae `channel: "rrss"`, pieza de calendario (`hook`, `body`, `cta`, `hashtags`, `pillar`), brandbook y, si existe, histórico de pipeline / posts orgánicos. **Respeta el calendario**: no inventes otro tema; mejora y visualiza lo planificado.
|
||||||
|
|
||||||
|
## Qué entregas por cada variante
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "ángulo corto alineado al pilar del calendario",
|
||||||
|
"hook": "titular EN la imagen, máx. 8 palabras",
|
||||||
|
"support": "línea de apoyo en imagen, máx. 12 palabras, o null",
|
||||||
|
"caption": "copy del post (hook + cuerpo + CTA + hashtags útiles), tono de marca, máx. 2200 caracteres",
|
||||||
|
"cta": "CTA suave orgánico (p. ej. 'Guarda este post', 'Escríbenos', 'Link en bio')",
|
||||||
|
"image_prompt": "prompt EN INGLÉS para Freepik, sin texto ni logo, vertical 4:5, aire para titular",
|
||||||
|
"layout": "foto-con-franja | tipografico | dato-grande",
|
||||||
|
"composition_notes": "montaje 4:5 en 3-5 líneas",
|
||||||
|
"rationale": "por qué encaja con el calendario y el ADN"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
- Prioriza el copy del calendario; puedes pulirlo, no cambiar de pilar.
|
||||||
|
- Sin claims prohibidos del brandbook. Sin emojis si la política lo prohíbe.
|
||||||
|
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
|
||||||
|
- Hashtags: solo si el calendario o la marca los usan; máx. 5–8, relevantes.
|
||||||
|
- Write-back: n8n guarda en `creative_pipeline` (cover + caption), no en `creatives` Ads.
|
||||||
24
skills/formats/reel.md
Normal file
24
skills/formats/reel.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Skill de formato · Reel RRSS (cover 9:16)
|
||||||
|
|
||||||
|
> Cover/keyframe de Reel **9:16**. El motion se produce aparte; aquí entregas imagen + caption del Reel.
|
||||||
|
|
||||||
|
## Entrega por variante
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "ángulo corto",
|
||||||
|
"hook": "texto en cover, máx. 7 palabras",
|
||||||
|
"support": "subtítulo o null",
|
||||||
|
"caption": "caption del Reel (gancho + valor + CTA + hashtags), tono de marca",
|
||||||
|
"cta": "CTA orgánico",
|
||||||
|
"image_prompt": "prompt EN INGLÉS, vertical 9:16, escena con movimiento implícito / freeze frame potente",
|
||||||
|
"layout": "foto-con-franja | tipografico | dato-grande",
|
||||||
|
"composition_notes": "cover 9:16 legible en feed de Reels",
|
||||||
|
"rationale": "encaje con calendario"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
- El cover debe leerse parado (scroll). Misma disciplina de marca que posts.
|
||||||
|
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 9:16 cinematic still with negative space for a short hook`.
|
||||||
25
skills/formats/story.md
Normal file
25
skills/formats/story.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Skill de formato · Stories RRSS (9:16)
|
||||||
|
|
||||||
|
> Pieza orgánica vertical **9:16 (1080×1920)** para Instagram/Facebook Stories. Canal `rrss` → Pipeline.
|
||||||
|
|
||||||
|
## Entrega por variante
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"angle": "ángulo corto",
|
||||||
|
"hook": "texto en story, máx. 6 palabras, muy legible",
|
||||||
|
"support": "sticker/pregunta o segunda línea, o null",
|
||||||
|
"caption": "texto opcional de acompañamiento / nota interna (Stories no siempre llevan caption largo)",
|
||||||
|
"cta": "sticker CTA (p. ej. 'Responde', 'Desliza', 'Enlace')",
|
||||||
|
"image_prompt": "prompt EN INGLÉS, vertical 9:16, zona segura superior/inferior para UI de Stories",
|
||||||
|
"layout": "foto-con-franja | tipografico | dato-grande",
|
||||||
|
"composition_notes": "safe zones 9:16, dónde va el hook",
|
||||||
|
"rationale": "encaje con calendario"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reglas
|
||||||
|
|
||||||
|
- Respeta la pieza del calendario (serie, interactiva, promo…).
|
||||||
|
- Aire para stickers y UI nativa. Sin claims prohibidos.
|
||||||
|
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 9:16 with safe margins for Instagram Stories UI`.
|
||||||
264
src/app/api/creative-pipeline/generate/route.ts
Normal file
264
src/app/api/creative-pipeline/generate/route.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||||
|
import {
|
||||||
|
BRANDBOOK_DOC_TYPE,
|
||||||
|
brandbookToPrompt,
|
||||||
|
parseBrandbook,
|
||||||
|
} from '@/lib/brandbook';
|
||||||
|
import {
|
||||||
|
buildRrssGenerationBrief,
|
||||||
|
generationRequestNoteMarker,
|
||||||
|
isRrssPipelineFormat,
|
||||||
|
type RrssPipelineFormat,
|
||||||
|
} from '@/lib/rrss-generation';
|
||||||
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function admin() {
|
||||||
|
return createAdminClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST — create pipeline piece + generation_request (channel=rrss) in one shot.
|
||||||
|
* Body: { title, format, month?, caption?, notes?, pillar?, calendar_piece_id?,
|
||||||
|
* calendar_piece?, scheduled_at?, channels?, variants_requested? }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (!isAgencyRole(userRole(user))) {
|
||||||
|
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = await resolveActiveTenant(user);
|
||||||
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Selecciona un cliente en Contexto' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'JSON inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = String(body.title || '').trim();
|
||||||
|
const format = body.format as RrssPipelineFormat;
|
||||||
|
if (!title) {
|
||||||
|
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!isRrssPipelineFormat(format)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'format debe ser post|story|reel|carrusel' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = admin();
|
||||||
|
const tenantId = ctx.tenantId;
|
||||||
|
|
||||||
|
const { data: tenant } = await db
|
||||||
|
.from('tenants')
|
||||||
|
.select('id, slug, name')
|
||||||
|
.eq('id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
// Recent pipeline for continuity
|
||||||
|
const { data: recentRows } = await db
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.select('title, format, caption')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(12);
|
||||||
|
|
||||||
|
// Brandbook
|
||||||
|
let brandbookPrompt: string | null = null;
|
||||||
|
const { data: brandDoc } = await db
|
||||||
|
.from('documents')
|
||||||
|
.select('content')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.eq('type', BRANDBOOK_DOC_TYPE)
|
||||||
|
.order('updated_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (brandDoc?.content) {
|
||||||
|
try {
|
||||||
|
const raw =
|
||||||
|
typeof brandDoc.content === 'string'
|
||||||
|
? JSON.parse(brandDoc.content)
|
||||||
|
: brandDoc.content;
|
||||||
|
const book = parseBrandbook(raw);
|
||||||
|
if (book) brandbookPrompt = brandbookToPrompt(book);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social cache (optional)
|
||||||
|
let socialHistory: unknown = null;
|
||||||
|
const { data: integs } = await db
|
||||||
|
.from('integrations')
|
||||||
|
.select('provider, meta')
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.in('provider', ['instagram', 'facebook']);
|
||||||
|
if (integs?.length) {
|
||||||
|
socialHistory = integs.map((i) => ({
|
||||||
|
provider: i.provider,
|
||||||
|
cache: (i.meta as { social_cache?: unknown } | null)?.social_cache || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarPieceId =
|
||||||
|
typeof body.calendar_piece_id === 'string' ? body.calendar_piece_id : null;
|
||||||
|
const calendarPiece =
|
||||||
|
body.calendar_piece && typeof body.calendar_piece === 'object'
|
||||||
|
? (body.calendar_piece as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const caption =
|
||||||
|
typeof body.caption === 'string' ? body.caption.trim() || null : null;
|
||||||
|
const pillar =
|
||||||
|
typeof body.pillar === 'string' ? body.pillar.trim() || null : null;
|
||||||
|
const month =
|
||||||
|
typeof body.month === 'string'
|
||||||
|
? body.month
|
||||||
|
: typeof calendarPiece?.date === 'string'
|
||||||
|
? String(calendarPiece.date).slice(0, 7)
|
||||||
|
: null;
|
||||||
|
const notesBase =
|
||||||
|
typeof body.notes === 'string' ? body.notes.trim() : pillar ? `Pilar: ${pillar}` : '';
|
||||||
|
|
||||||
|
const { data: item, error: insErr } = await db
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.insert({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
title,
|
||||||
|
format,
|
||||||
|
month,
|
||||||
|
status: 'borrador',
|
||||||
|
caption,
|
||||||
|
notes: notesBase || null,
|
||||||
|
calendar_piece_id: calendarPieceId,
|
||||||
|
scheduled_at:
|
||||||
|
typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
|
||||||
|
channels: Array.isArray(body.channels)
|
||||||
|
? body.channels
|
||||||
|
: ['instagram', 'facebook'],
|
||||||
|
created_by: user.id,
|
||||||
|
})
|
||||||
|
.select('*')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (insErr || !item) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: insErr?.message || 'No se pudo crear la pieza' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const brief = buildRrssGenerationBrief({
|
||||||
|
tenantId,
|
||||||
|
clientSlug: tenant?.slug || 'cliente',
|
||||||
|
clientName: tenant?.name || ctx.label || 'Cliente',
|
||||||
|
format,
|
||||||
|
pipelineId: item.id,
|
||||||
|
calendarPieceId,
|
||||||
|
title,
|
||||||
|
caption,
|
||||||
|
notes: notesBase,
|
||||||
|
pillar,
|
||||||
|
month,
|
||||||
|
scheduledAt:
|
||||||
|
typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
|
||||||
|
calendarPiece: calendarPiece
|
||||||
|
? {
|
||||||
|
id: calendarPieceId,
|
||||||
|
title: String(calendarPiece.title || title),
|
||||||
|
date: calendarPiece.date ? String(calendarPiece.date) : null,
|
||||||
|
time: calendarPiece.time ? String(calendarPiece.time) : null,
|
||||||
|
format: calendarPiece.format ? String(calendarPiece.format) : format,
|
||||||
|
pillar: calendarPiece.pillar
|
||||||
|
? String(calendarPiece.pillar)
|
||||||
|
: pillar,
|
||||||
|
hook: calendarPiece.hook ? String(calendarPiece.hook) : null,
|
||||||
|
body: calendarPiece.body
|
||||||
|
? String(calendarPiece.body)
|
||||||
|
: caption,
|
||||||
|
cta: calendarPiece.cta ? String(calendarPiece.cta) : null,
|
||||||
|
hashtags: calendarPiece.hashtags
|
||||||
|
? String(calendarPiece.hashtags)
|
||||||
|
: null,
|
||||||
|
platform: calendarPiece.platform
|
||||||
|
? String(calendarPiece.platform)
|
||||||
|
: 'instagram',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
recentPipeline: (recentRows || []).map((r) => ({
|
||||||
|
title: r.title,
|
||||||
|
format: r.format,
|
||||||
|
caption: r.caption,
|
||||||
|
})),
|
||||||
|
brandbookPrompt,
|
||||||
|
socialHistory,
|
||||||
|
variantsRequested: Number(body.variants_requested || 2),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: gr, error: grErr } = await db
|
||||||
|
.from('generation_requests')
|
||||||
|
.insert({
|
||||||
|
tenant_id: tenantId,
|
||||||
|
reason: 'calendario',
|
||||||
|
source_creative_id: null,
|
||||||
|
brief,
|
||||||
|
status: 'pendiente',
|
||||||
|
})
|
||||||
|
.select('*')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (grErr || !gr) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: grErr?.message || 'Pieza creada pero falló la cola Claude',
|
||||||
|
item,
|
||||||
|
},
|
||||||
|
{ status: 502 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const marker = generationRequestNoteMarker(gr.id);
|
||||||
|
const notesWithMarker = [notesBase, marker, 'Auto: Claude + Freepik + Canva']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
await db
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.update({ notes: notesWithMarker, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', item.id)
|
||||||
|
.eq('tenant_id', tenantId);
|
||||||
|
|
||||||
|
dispatchN8nWebhookAsync('generation_request.created', {
|
||||||
|
generation_request: { ...gr, client_id: tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
item: { ...item, notes: notesWithMarker },
|
||||||
|
generation_request: gr,
|
||||||
|
message:
|
||||||
|
'Pieza en cola. n8n/Claude generará cover+caption y escribirá en el pipeline.',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ import { isAgencyRole, userRole } from '@/lib/agency';
|
|||||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||||
import { staticAdBriefAspectFields } from '@/lib/creatives-aspect-ratios';
|
import { staticAdBriefAspectFields } from '@/lib/creatives-aspect-ratios';
|
||||||
|
import {
|
||||||
|
isRrssPipelineFormat,
|
||||||
|
rrssBriefAspectFields,
|
||||||
|
} from '@/lib/rrss-generation';
|
||||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
function getAdmin() {
|
function getAdmin() {
|
||||||
@@ -145,23 +149,48 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const avoidAngles = body.avoid_angles || (creativeAngle ? [creativeAngle] : []);
|
const avoidAngles = body.avoid_angles || (creativeAngle ? [creativeAngle] : []);
|
||||||
|
|
||||||
const format = body.format || 'estatico';
|
const channel =
|
||||||
|
body.channel === 'rrss' || body.brief?.channel === 'rrss' ? 'rrss' : 'ads';
|
||||||
|
const format =
|
||||||
|
body.format ||
|
||||||
|
(channel === 'rrss' ? 'post' : 'estatico');
|
||||||
|
|
||||||
|
const aspectFields =
|
||||||
|
channel === 'rrss' && isRrssPipelineFormat(format)
|
||||||
|
? rrssBriefAspectFields(format)
|
||||||
|
: staticAdBriefAspectFields(format);
|
||||||
|
|
||||||
const brief = {
|
const brief = {
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
client_id: tenantId,
|
client_id: tenantId,
|
||||||
client_slug: tenant?.slug || 'cliente',
|
client_slug: tenant?.slug || 'cliente',
|
||||||
reason: body.reason || 'manual',
|
client_name: tenant?.name || null,
|
||||||
source_creative_id: body.source_creative_id || null,
|
channel,
|
||||||
|
reason: body.reason || (channel === 'rrss' ? 'calendario' : 'manual'),
|
||||||
|
source_creative_id:
|
||||||
|
channel === 'rrss' ? null : body.source_creative_id || null,
|
||||||
|
source_pipeline_id: body.source_pipeline_id || null,
|
||||||
|
calendar_piece_id: body.calendar_piece_id || null,
|
||||||
format,
|
format,
|
||||||
...staticAdBriefAspectFields(format),
|
skill_format: body.skill_format || format,
|
||||||
variants_requested: Number(body.variants_requested || 3),
|
...aspectFields,
|
||||||
campaign_name: campaignName,
|
variants_requested: Number(
|
||||||
adset_id: adsetId,
|
body.variants_requested || (channel === 'rrss' ? 2 : 3)
|
||||||
audience,
|
),
|
||||||
product,
|
campaign_name: channel === 'rrss' ? null : campaignName,
|
||||||
landing_url: landingUrl,
|
adset_id: channel === 'rrss' ? null : adsetId,
|
||||||
|
audience: channel === 'rrss' ? null : audience,
|
||||||
|
product: channel === 'rrss' ? null : product,
|
||||||
|
landing_url: channel === 'rrss' ? null : landingUrl,
|
||||||
avoid_angles: avoidAngles,
|
avoid_angles: avoidAngles,
|
||||||
|
title: body.title || null,
|
||||||
|
caption: body.caption || null,
|
||||||
|
pillar: body.pillar || null,
|
||||||
|
month: body.month || null,
|
||||||
|
scheduled_at: body.scheduled_at || null,
|
||||||
|
calendar_piece: body.calendar_piece || null,
|
||||||
|
context: body.context || null,
|
||||||
|
write_back: body.write_back || null,
|
||||||
notes: body.notes || null,
|
notes: body.notes || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,21 +210,23 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registrar en creative_events
|
// Registrar en creative_events (solo Ads: FK a creatives)
|
||||||
await admin.from('creative_events').insert({
|
if (channel === 'ads' && brief.source_creative_id) {
|
||||||
tenant_id: tenantId,
|
await admin.from('creative_events').insert({
|
||||||
creative_id: brief.source_creative_id,
|
tenant_id: tenantId,
|
||||||
event_type: 'generation_requested',
|
creative_id: brief.source_creative_id,
|
||||||
actor: user ? `user:${user.id}` : 'agency',
|
event_type: 'generation_requested',
|
||||||
from_status: null,
|
actor: user ? `user:${user.id}` : 'agency',
|
||||||
to_status: null,
|
from_status: null,
|
||||||
payload: {
|
to_status: null,
|
||||||
generation_request_id: created.id,
|
payload: {
|
||||||
reason: brief.reason,
|
generation_request_id: created.id,
|
||||||
variants_requested: brief.variants_requested,
|
reason: brief.reason,
|
||||||
},
|
variants_requested: brief.variants_requested,
|
||||||
visibility: 'agency',
|
},
|
||||||
});
|
visibility: 'agency',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const enrichedRequest = { ...created, client_id: created.tenant_id };
|
const enrichedRequest = { ...created, client_id: created.tenant_id };
|
||||||
dispatchN8nWebhookAsync('generation_request.created', {
|
dispatchN8nWebhookAsync('generation_request.created', {
|
||||||
|
|||||||
240
src/app/api/integrations/creative-pipeline/route.ts
Normal file
240
src/app/api/integrations/creative-pipeline/route.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||||
|
import {
|
||||||
|
parseGenerationRequestIdFromNotes,
|
||||||
|
stripGenerationRequestMarker,
|
||||||
|
} from '@/lib/rrss-generation';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
function admin() {
|
||||||
|
return createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadToStorage(opts: {
|
||||||
|
tenantId: string;
|
||||||
|
pipelineId: string;
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const svc = serviceSupabase();
|
||||||
|
if (!svc) return null;
|
||||||
|
try {
|
||||||
|
const r = await fetch(opts.url, { cache: 'no-store' });
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
const contentType = r.headers.get('content-type') || 'image/jpeg';
|
||||||
|
const ext = contentType.includes('png')
|
||||||
|
? 'png'
|
||||||
|
: contentType.includes('webp')
|
||||||
|
? 'webp'
|
||||||
|
: 'jpg';
|
||||||
|
const path = `${opts.tenantId}/pipeline/${opts.pipelineId}/${opts.filename}.${ext}`;
|
||||||
|
const up = await svc.storage.from(ASSETS_BUCKET).upload(path, buf, {
|
||||||
|
contentType,
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
if (up.error) return null;
|
||||||
|
return path;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST — n8n write-back for RRSS auto-generation.
|
||||||
|
* Body: {
|
||||||
|
* generation_request_id?,
|
||||||
|
* pipeline_id?,
|
||||||
|
* asset_url?, // final or Freepik raw
|
||||||
|
* stories_asset_url?,
|
||||||
|
* caption?,
|
||||||
|
* hook?, support?,
|
||||||
|
* slide_urls?: string[],
|
||||||
|
* status?: 'borrador'|'revision_agencia',
|
||||||
|
* mark_request?: 'completada'|'fallida',
|
||||||
|
* error?: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await authenticateIntegrationRequest(req, {
|
||||||
|
requireTenant: true,
|
||||||
|
parsedBody: body,
|
||||||
|
});
|
||||||
|
if (!auth.ok) return auth.errorResponse!;
|
||||||
|
|
||||||
|
const tenantId = auth.tenantId!;
|
||||||
|
const db = admin();
|
||||||
|
|
||||||
|
const grId =
|
||||||
|
typeof body.generation_request_id === 'string'
|
||||||
|
? body.generation_request_id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let pipelineId =
|
||||||
|
typeof body.pipeline_id === 'string' ? body.pipeline_id : null;
|
||||||
|
|
||||||
|
if (!pipelineId && grId) {
|
||||||
|
const { data: gr } = await db
|
||||||
|
.from('generation_requests')
|
||||||
|
.select('brief')
|
||||||
|
.eq('id', grId)
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
const brief = (gr?.brief || {}) as {
|
||||||
|
source_pipeline_id?: string;
|
||||||
|
write_back?: { pipeline_id?: string };
|
||||||
|
};
|
||||||
|
pipelineId =
|
||||||
|
brief.source_pipeline_id || brief.write_back?.pipeline_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pipelineId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'pipeline_id or generation_request_id with source_pipeline_id required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: item } = await db
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.select('id, notes, caption, format')
|
||||||
|
.eq('id', pipelineId)
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return NextResponse.json({ error: 'Pipeline piece not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetUrl =
|
||||||
|
typeof body.asset_url === 'string'
|
||||||
|
? body.asset_url
|
||||||
|
: typeof body.stories_asset_url === 'string'
|
||||||
|
? body.stories_asset_url
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let coverPath: string | null = null;
|
||||||
|
if (assetUrl) {
|
||||||
|
if (assetUrl.startsWith('http')) {
|
||||||
|
coverPath = await downloadToStorage({
|
||||||
|
tenantId,
|
||||||
|
pipelineId,
|
||||||
|
url: assetUrl,
|
||||||
|
filename: 'cover-ai',
|
||||||
|
});
|
||||||
|
// Fallback: store URL so UI can still show something via <img src>
|
||||||
|
if (!coverPath) coverPath = assetUrl;
|
||||||
|
} else {
|
||||||
|
coverPath = assetUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slideUrls = Array.isArray(body.slide_urls)
|
||||||
|
? body.slide_urls.filter((u): u is string => typeof u === 'string')
|
||||||
|
: [];
|
||||||
|
const slidePaths: string[] = [];
|
||||||
|
for (let i = 0; i < slideUrls.length; i++) {
|
||||||
|
const u = slideUrls[i];
|
||||||
|
if (u.startsWith('http')) {
|
||||||
|
const p = await downloadToStorage({
|
||||||
|
tenantId,
|
||||||
|
pipelineId,
|
||||||
|
url: u,
|
||||||
|
filename: `slide-${i + 1}-ai`,
|
||||||
|
});
|
||||||
|
slidePaths.push(p || u);
|
||||||
|
} else {
|
||||||
|
slidePaths.push(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const caption =
|
||||||
|
typeof body.caption === 'string'
|
||||||
|
? body.caption
|
||||||
|
: [body.hook, body.support, body.primary_text]
|
||||||
|
.filter((x) => typeof x === 'string' && x.trim())
|
||||||
|
.join('\n\n') || item.caption;
|
||||||
|
|
||||||
|
const status =
|
||||||
|
body.status === 'revision_agencia' || body.status === 'borrador'
|
||||||
|
? body.status
|
||||||
|
: coverPath
|
||||||
|
? 'revision_agencia'
|
||||||
|
: 'borrador';
|
||||||
|
|
||||||
|
const cleanNotes = stripGenerationRequestMarker(item.notes);
|
||||||
|
const grMarker = grId || parseGenerationRequestIdFromNotes(item.notes);
|
||||||
|
const notes = [
|
||||||
|
cleanNotes,
|
||||||
|
grMarker ? `[[generation_request:${grMarker}]]` : null,
|
||||||
|
coverPath ? 'Generado: Claude + Freepik + Canva' : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
notes,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
if (coverPath) {
|
||||||
|
patch.cover_path = coverPath;
|
||||||
|
patch.cover_mime = coverPath.includes('.png')
|
||||||
|
? 'image/png'
|
||||||
|
: coverPath.includes('.webp')
|
||||||
|
? 'image/webp'
|
||||||
|
: 'image/jpeg';
|
||||||
|
}
|
||||||
|
if (caption) patch.caption = caption;
|
||||||
|
if (slidePaths.length) patch.slide_paths = slidePaths;
|
||||||
|
|
||||||
|
const { data: updated, error } = await db
|
||||||
|
.from('creative_pipeline')
|
||||||
|
.update(patch)
|
||||||
|
.eq('id', pipelineId)
|
||||||
|
.eq('tenant_id', tenantId)
|
||||||
|
.select('*')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grId) {
|
||||||
|
const mark =
|
||||||
|
body.mark_request === 'fallida'
|
||||||
|
? 'fallida'
|
||||||
|
: body.error
|
||||||
|
? 'fallida'
|
||||||
|
: 'completada';
|
||||||
|
await db
|
||||||
|
.from('generation_requests')
|
||||||
|
.update({
|
||||||
|
status: mark,
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
error: typeof body.error === 'string' ? body.error : null,
|
||||||
|
result: {
|
||||||
|
pipeline_id: pipelineId,
|
||||||
|
cover_path: coverPath,
|
||||||
|
notes: 'rrss write-back',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.eq('id', grId)
|
||||||
|
.eq('tenant_id', tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, item: updated });
|
||||||
|
}
|
||||||
@@ -36,7 +36,14 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slug = (tenant.slug || 'bps').toLowerCase().trim();
|
const slug = (tenant.slug || 'bps').toLowerCase().trim();
|
||||||
const formatParam = (req.nextUrl.searchParams.get('format') || 'estatico').toLowerCase().trim();
|
// Prefer explicit format; RRSS briefs also send skill_format=post|story|reel|carrusel
|
||||||
|
const formatParam = (
|
||||||
|
req.nextUrl.searchParams.get('skill_format') ||
|
||||||
|
req.nextUrl.searchParams.get('format') ||
|
||||||
|
'estatico'
|
||||||
|
)
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
// 2. Obtener brandbook de documents (fuente central de verdad: Zona de Marca)
|
// 2. Obtener brandbook de documents (fuente central de verdad: Zona de Marca)
|
||||||
const { data: brandDoc } = await adminClient
|
const { data: brandDoc } = await adminClient
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
scheduledAtFromPiece,
|
scheduledAtFromPiece,
|
||||||
} from '@/lib/calendar-documents';
|
} from '@/lib/calendar-documents';
|
||||||
import { captionHasProductionNoise } from '@/lib/publish-caption';
|
import { captionHasProductionNoise } from '@/lib/publish-caption';
|
||||||
|
import { parseGenerationRequestIdFromNotes } from '@/lib/rrss-generation';
|
||||||
import {
|
import {
|
||||||
AGENCY_STATUS_ORDER,
|
AGENCY_STATUS_ORDER,
|
||||||
APPROVAL_COLOR,
|
APPROVAL_COLOR,
|
||||||
@@ -95,6 +96,7 @@ const FORMAT_CONFIG: Record<string, { label: string; badgeCls: string }> = {
|
|||||||
|
|
||||||
function mediaUrl(path: string | null) {
|
function mediaUrl(path: string | null) {
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
|
if (/^https?:\/\//i.test(path)) return path;
|
||||||
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
|
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +140,8 @@ export default function CreativePipelineBoard() {
|
|||||||
const [newDate, setNewDate] = useState('');
|
const [newDate, setNewDate] = useState('');
|
||||||
const [newTime, setNewTime] = useState('10:00');
|
const [newTime, setNewTime] = useState('10:00');
|
||||||
const [syncCalendar, setSyncCalendar] = useState(true);
|
const [syncCalendar, setSyncCalendar] = useState(true);
|
||||||
|
/** manual = upload creativo; auto = Claude + Freepik + Canva (mismo motor Ads) */
|
||||||
|
const [creationMode, setCreationMode] = useState<'manual' | 'auto'>('manual');
|
||||||
const [coverFile, setCoverFile] = useState<File | null>(null);
|
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||||
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
||||||
const [slideFiles, setSlideFiles] = useState<File[]>([]);
|
const [slideFiles, setSlideFiles] = useState<File[]>([]);
|
||||||
@@ -277,6 +281,7 @@ export default function CreativePipelineBoard() {
|
|||||||
setNewDate('');
|
setNewDate('');
|
||||||
setNewTime('10:00');
|
setNewTime('10:00');
|
||||||
setSyncCalendar(true);
|
setSyncCalendar(true);
|
||||||
|
setCreationMode('manual');
|
||||||
clearMediaSelection();
|
clearMediaSelection();
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
}
|
}
|
||||||
@@ -339,6 +344,10 @@ export default function CreativePipelineBoard() {
|
|||||||
try {
|
try {
|
||||||
let calendarPieceId: string | null =
|
let calendarPieceId: string | null =
|
||||||
calPick !== '__new__' ? calPick : null;
|
calPick !== '__new__' ? calPick : null;
|
||||||
|
const pickedCal =
|
||||||
|
calendarPieceId && calendarPieceId !== '__new__'
|
||||||
|
? calPieces.find((p) => p.id === calendarPieceId)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (syncCalendar) {
|
if (syncCalendar) {
|
||||||
const date =
|
const date =
|
||||||
@@ -374,6 +383,60 @@ export default function CreativePipelineBoard() {
|
|||||||
? scheduledAtFromPiece({ date: newDate, time: newTime })
|
? scheduledAtFromPiece({ date: newDate, time: newTime })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
if (creationMode === 'auto') {
|
||||||
|
const res = await fetch('/api/creative-pipeline/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: newTitle.trim(),
|
||||||
|
format: newFormat,
|
||||||
|
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
|
||||||
|
caption: newCaption || null,
|
||||||
|
notes: newPillar ? `Pilar: ${newPillar}` : null,
|
||||||
|
pillar: newPillar || null,
|
||||||
|
calendar_piece_id: calendarPieceId,
|
||||||
|
scheduled_at: sched,
|
||||||
|
channels: ['instagram', 'facebook'],
|
||||||
|
calendar_piece: pickedCal
|
||||||
|
? {
|
||||||
|
id: pickedCal.id,
|
||||||
|
title: pickedCal.title,
|
||||||
|
date: pickedCal.date,
|
||||||
|
time: pickedCal.time,
|
||||||
|
format: pickedCal.format,
|
||||||
|
pillar: pickedCal.pillar || newPillar,
|
||||||
|
hook: newHook || pickedCal.hook,
|
||||||
|
body: newCaption || pickedCal.body,
|
||||||
|
cta: pickedCal.cta,
|
||||||
|
hashtags: pickedCal.hashtags,
|
||||||
|
platform: pickedCal.platform || 'instagram',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: newTitle.trim(),
|
||||||
|
date: newDate || null,
|
||||||
|
time: newTime || null,
|
||||||
|
pillar: newPillar || null,
|
||||||
|
hook: newHook || null,
|
||||||
|
body: newCaption || null,
|
||||||
|
platform: 'instagram',
|
||||||
|
},
|
||||||
|
variants_requested: 2,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error || 'No se pudo encolar la generación RRSS');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
setWarning(
|
||||||
|
data.message ||
|
||||||
|
'Pieza en cola: Claude + Freepik + Canva generarán el creativo (mismo motor que Ads).'
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/creative-pipeline', {
|
const res = await fetch('/api/creative-pipeline', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -1005,91 +1068,131 @@ export default function CreativePipelineBoard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3 rounded-xl border border-gray-200 bg-gray-50 p-3 space-y-2">
|
||||||
<label className="block text-xs font-semibold text-gray-700">
|
<p className="text-xs font-semibold text-gray-800">
|
||||||
Creativo / imagen
|
Cómo crear el creativo
|
||||||
</label>
|
|
||||||
<p className="mt-0.5 text-[11px] text-gray-500">
|
|
||||||
Sube la portada aquí (jpg/png/webp). En carrusel puedes añadir
|
|
||||||
slides extra.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap items-start gap-4">
|
<div className="flex flex-wrap gap-2">
|
||||||
<label className="relative flex h-36 w-28 cursor-pointer flex-col items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 hover:border-brand-500 hover:bg-brand-50/40">
|
<button
|
||||||
{coverPreview ? (
|
type="button"
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
onClick={() => setCreationMode('manual')}
|
||||||
<img
|
className={`rounded-lg px-3 py-2 text-xs font-semibold border ${
|
||||||
src={coverPreview}
|
creationMode === 'manual'
|
||||||
alt="Portada"
|
? 'bg-gray-900 text-white border-gray-900'
|
||||||
className="h-full w-full object-cover"
|
: 'bg-white text-gray-700 border-gray-200'
|
||||||
/>
|
}`}
|
||||||
) : (
|
>
|
||||||
<>
|
Manual — subo yo la imagen
|
||||||
<ImagePlus className="h-7 w-7 text-gray-400" />
|
</button>
|
||||||
<span className="mt-1 px-2 text-center text-[10px] font-medium text-gray-500">
|
<button
|
||||||
Portada
|
type="button"
|
||||||
</span>
|
onClick={() => {
|
||||||
</>
|
setCreationMode('auto');
|
||||||
)}
|
clearMediaSelection();
|
||||||
<input
|
}}
|
||||||
type="file"
|
className={`rounded-lg px-3 py-2 text-xs font-semibold border ${
|
||||||
accept="image/jpeg,image/png,image/webp"
|
creationMode === 'auto'
|
||||||
className="absolute inset-0 cursor-pointer opacity-0"
|
? 'bg-brand-600 text-white border-brand-600'
|
||||||
onChange={(e) =>
|
: 'bg-white text-gray-700 border-gray-200'
|
||||||
pickCover(e.target.files?.[0] || null)
|
}`}
|
||||||
}
|
>
|
||||||
/>
|
Auto — Claude + Freepik + Canva
|
||||||
</label>
|
</button>
|
||||||
{coverFile ? (
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-1 pt-1">
|
|
||||||
<p className="truncate text-xs font-medium text-gray-800">
|
|
||||||
{coverFile.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-gray-500">
|
|
||||||
{(coverFile.size / 1024).toFixed(0)} KB
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => pickCover(null)}
|
|
||||||
className="w-fit text-xs font-medium text-red-600 hover:underline"
|
|
||||||
>
|
|
||||||
Quitar portada
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
{creationMode === 'auto'
|
||||||
|
? 'Usa Zona de marca, el calendario editorial y el histórico RRSS/pipeline. Misma cola n8n que Ads (channel=rrss); el resultado vuelve al pipeline.'
|
||||||
|
: 'Sube portada (y slides si es carrusel) como hasta ahora.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{newFormat === 'carrusel' ? (
|
{creationMode === 'manual' ? (
|
||||||
<div className="mt-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-xs font-medium text-gray-600">
|
<label className="block text-xs font-semibold text-gray-700">
|
||||||
Slides del carrusel (opcionales)
|
Creativo / imagen
|
||||||
|
</label>
|
||||||
|
<p className="mt-0.5 text-[11px] text-gray-500">
|
||||||
|
Sube la portada aquí (jpg/png/webp). En carrusel puedes añadir
|
||||||
|
slides extra.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-start gap-4">
|
||||||
|
<label className="relative flex h-36 w-28 cursor-pointer flex-col items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 hover:border-brand-500 hover:bg-brand-50/40">
|
||||||
|
{coverPreview ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={coverPreview}
|
||||||
|
alt="Portada"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ImagePlus className="h-7 w-7 text-gray-400" />
|
||||||
|
<span className="mt-1 px-2 text-center text-[10px] font-medium text-gray-500">
|
||||||
|
Portada
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
className="absolute inset-0 cursor-pointer opacity-0"
|
||||||
|
onChange={(e) =>
|
||||||
|
pickCover(e.target.files?.[0] || null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<input
|
{coverFile ? (
|
||||||
type="file"
|
<div className="flex min-w-0 flex-1 flex-col gap-1 pt-1">
|
||||||
accept="image/jpeg,image/png,image/webp"
|
<p className="truncate text-xs font-medium text-gray-800">
|
||||||
multiple
|
{coverFile.name}
|
||||||
className="mt-1 block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-gray-900 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-white hover:file:bg-gray-800"
|
</p>
|
||||||
onChange={(e) => pickSlides(e.target.files)}
|
<p className="text-[11px] text-gray-500">
|
||||||
/>
|
{(coverFile.size / 1024).toFixed(0)} KB
|
||||||
{slidePreviews.length > 0 ? (
|
</p>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<button
|
||||||
{slidePreviews.map((src, i) => (
|
type="button"
|
||||||
<div
|
onClick={() => pickCover(null)}
|
||||||
key={i}
|
className="w-fit text-xs font-medium text-red-600 hover:underline"
|
||||||
className="relative h-16 w-12 overflow-hidden rounded-lg border border-gray-200"
|
>
|
||||||
>
|
Quitar portada
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
</button>
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={`Slide ${i + 1}`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
{newFormat === 'carrusel' ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-xs font-medium text-gray-600">
|
||||||
|
Slides del carrusel (opcionales)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp"
|
||||||
|
multiple
|
||||||
|
className="mt-1 block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-gray-900 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-white hover:file:bg-gray-800"
|
||||||
|
onChange={(e) => pickSlides(e.target.files)}
|
||||||
|
/>
|
||||||
|
{slidePreviews.length > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{slidePreviews.map((src, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="relative h-16 w-12 overflow-hidden rounded-lg border border-gray-200"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={`Slide ${i + 1}`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-start gap-2 text-xs text-gray-700">
|
<label className="flex items-start gap-2 text-xs text-gray-700">
|
||||||
@@ -1113,12 +1216,16 @@ export default function CreativePipelineBoard() {
|
|||||||
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-60"
|
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{creating
|
{creating
|
||||||
? coverFile
|
? creationMode === 'auto'
|
||||||
? 'Creando y subiendo creativo…'
|
? 'Encolando Claude + Freepik + Canva…'
|
||||||
: 'Creando…'
|
: coverFile
|
||||||
: calPick === '__new__'
|
? 'Creando y subiendo creativo…'
|
||||||
? 'Crear en pipeline'
|
: 'Creando…'
|
||||||
: 'Añadir al pipeline (desde calendario)'}
|
: creationMode === 'auto'
|
||||||
|
? 'Crear con IA (RRSS)'
|
||||||
|
: calPick === '__new__'
|
||||||
|
? 'Crear en pipeline'
|
||||||
|
: 'Añadir al pipeline (desde calendario)'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1260,6 +1367,8 @@ function PieceCard({
|
|||||||
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||||
};
|
};
|
||||||
const igUrl = getInstagramUrl(item.notes);
|
const igUrl = getInstagramUrl(item.notes);
|
||||||
|
const autoGenPending =
|
||||||
|
Boolean(parseGenerationRequestIdFromNotes(item.notes)) && !item.cover_path;
|
||||||
|
|
||||||
const status = normalizeApprovalStatus(item.status);
|
const status = normalizeApprovalStatus(item.status);
|
||||||
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
|
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
|
||||||
@@ -1331,6 +1440,11 @@ function PieceCard({
|
|||||||
{slides.length} slides
|
{slides.length} slides
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{autoGenPending ? (
|
||||||
|
<span className="rounded-md bg-violet-600/90 px-1.5 py-0.5 text-[10px] font-bold uppercase text-white shadow-sm">
|
||||||
|
IA en cola
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Botón de vista rápida */}
|
{/* Botón de vista rápida */}
|
||||||
|
|||||||
@@ -109,8 +109,14 @@ export interface GenerationRequestBrief {
|
|||||||
client_slug: string;
|
client_slug: string;
|
||||||
reason: GenerationRequestReason;
|
reason: GenerationRequestReason;
|
||||||
source_creative_id?: string | null;
|
source_creative_id?: string | null;
|
||||||
format: 'estatico' | 'carrusel' | 'video';
|
/** ads (default) | rrss — RRSS writes back to creative_pipeline */
|
||||||
/** Primary design ratio (always 4:5 for estático). */
|
channel?: 'ads' | 'rrss';
|
||||||
|
source_pipeline_id?: string | null;
|
||||||
|
calendar_piece_id?: string | null;
|
||||||
|
format: 'estatico' | 'carrusel' | 'video' | 'post' | 'story' | 'reel';
|
||||||
|
/** Format key for skills/formats/*.md (RRSS may differ from format). */
|
||||||
|
skill_format?: string;
|
||||||
|
/** Primary design ratio (always 4:5 for estático ads). */
|
||||||
aspect_ratio?: string;
|
aspect_ratio?: string;
|
||||||
/** All placements to produce for estático: 4:5, 9:16, 1.91:1. */
|
/** All placements to produce for estático: 4:5, 9:16, 1.91:1. */
|
||||||
aspect_ratios?: string[];
|
aspect_ratios?: string[];
|
||||||
@@ -127,6 +133,13 @@ export interface GenerationRequestBrief {
|
|||||||
adset_id?: string | null;
|
adset_id?: string | null;
|
||||||
language?: string;
|
language?: string;
|
||||||
deadline?: string;
|
deadline?: string;
|
||||||
|
/** RRSS editorial context (calendar piece, brandbook, history). */
|
||||||
|
calendar_piece?: Record<string, unknown> | null;
|
||||||
|
context?: Record<string, unknown> | null;
|
||||||
|
write_back?: Record<string, unknown> | null;
|
||||||
|
notes?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
caption?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenerationRequestResult {
|
export interface GenerationRequestResult {
|
||||||
|
|||||||
190
src/lib/rrss-generation.ts
Normal file
190
src/lib/rrss-generation.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* RRSS auto-generation: reuse Ads generation_requests → n8n (Claude + Freepik + Canva)
|
||||||
|
* with channel=rrss and write-back into creative_pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RrssPipelineFormat = 'post' | 'story' | 'reel' | 'carrusel';
|
||||||
|
|
||||||
|
export type RrssPlacement = {
|
||||||
|
aspect_ratio: string;
|
||||||
|
role: string;
|
||||||
|
size: string;
|
||||||
|
meta_key: string;
|
||||||
|
canva_template_key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Placements per RRSS format (not the Ads triple 4:5+9:16+1.91:1). */
|
||||||
|
export function rrssBriefAspectFields(format: RrssPipelineFormat): {
|
||||||
|
aspect_ratio: string;
|
||||||
|
aspect_ratios: string[];
|
||||||
|
required_placements: RrssPlacement[];
|
||||||
|
} {
|
||||||
|
if (format === 'story') {
|
||||||
|
const p: RrssPlacement = {
|
||||||
|
aspect_ratio: '9:16',
|
||||||
|
role: 'stories',
|
||||||
|
size: '1080x1920',
|
||||||
|
meta_key: 'asset_url',
|
||||||
|
canva_template_key: 'stories_9_16',
|
||||||
|
};
|
||||||
|
return { aspect_ratio: '9:16', aspect_ratios: ['9:16'], required_placements: [p] };
|
||||||
|
}
|
||||||
|
if (format === 'reel') {
|
||||||
|
const p: RrssPlacement = {
|
||||||
|
aspect_ratio: '9:16',
|
||||||
|
role: 'reel',
|
||||||
|
size: '1080x1920',
|
||||||
|
meta_key: 'asset_url',
|
||||||
|
canva_template_key: 'stories_9_16',
|
||||||
|
};
|
||||||
|
return { aspect_ratio: '9:16', aspect_ratios: ['9:16'], required_placements: [p] };
|
||||||
|
}
|
||||||
|
if (format === 'carrusel') {
|
||||||
|
const p: RrssPlacement = {
|
||||||
|
aspect_ratio: '4:5',
|
||||||
|
role: 'carousel_slide',
|
||||||
|
size: '1080x1350',
|
||||||
|
meta_key: 'asset_url',
|
||||||
|
canva_template_key: 'foto-con-franja',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
aspect_ratio: '4:5',
|
||||||
|
aspect_ratios: ['4:5'],
|
||||||
|
required_placements: [p],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// post feed
|
||||||
|
const p: RrssPlacement = {
|
||||||
|
aspect_ratio: '4:5',
|
||||||
|
role: 'feed',
|
||||||
|
size: '1080x1350',
|
||||||
|
meta_key: 'asset_url',
|
||||||
|
canva_template_key: 'foto-con-franja',
|
||||||
|
};
|
||||||
|
return { aspect_ratio: '4:5', aspect_ratios: ['4:5'], required_placements: [p] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRrssPipelineFormat(f: unknown): f is RrssPipelineFormat {
|
||||||
|
return f === 'post' || f === 'story' || f === 'reel' || f === 'carrusel';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRrssFormatToSkillFormat(format: RrssPipelineFormat): string {
|
||||||
|
return format; // skills/formats/{post,story,reel,carrusel}.md
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RrssCalendarPieceBrief = {
|
||||||
|
id?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
date?: string | null;
|
||||||
|
time?: string | null;
|
||||||
|
format?: string | null;
|
||||||
|
pillar?: string | null;
|
||||||
|
hook?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
cta?: string | null;
|
||||||
|
hashtags?: string | null;
|
||||||
|
platform?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RrssGenerationBriefInput = {
|
||||||
|
tenantId: string;
|
||||||
|
clientSlug: string;
|
||||||
|
clientName: string;
|
||||||
|
format: RrssPipelineFormat;
|
||||||
|
pipelineId: string;
|
||||||
|
calendarPieceId?: string | null;
|
||||||
|
title: string;
|
||||||
|
caption?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
pillar?: string | null;
|
||||||
|
month?: string | null;
|
||||||
|
scheduledAt?: string | null;
|
||||||
|
calendarPiece?: RrssCalendarPieceBrief | null;
|
||||||
|
/** Recent pipeline captions/titles for style continuity */
|
||||||
|
recentPipeline?: Array<{ title: string; format: string; caption?: string | null }>;
|
||||||
|
/** Brandbook prompt snippet (already compiled or raw fields) */
|
||||||
|
brandbookPrompt?: string | null;
|
||||||
|
/** Recent organic posts snapshot */
|
||||||
|
socialHistory?: unknown;
|
||||||
|
variantsRequested?: number;
|
||||||
|
extraNotes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
|
||||||
|
const aspects = rrssBriefAspectFields(input.format);
|
||||||
|
const cal = input.calendarPiece || {};
|
||||||
|
return {
|
||||||
|
channel: 'rrss' as const,
|
||||||
|
tenant_id: input.tenantId,
|
||||||
|
client_id: input.tenantId,
|
||||||
|
client_slug: input.clientSlug,
|
||||||
|
client_name: input.clientName,
|
||||||
|
reason: 'calendario' as const,
|
||||||
|
source_creative_id: null,
|
||||||
|
source_pipeline_id: input.pipelineId,
|
||||||
|
calendar_piece_id: input.calendarPieceId || cal.id || null,
|
||||||
|
format: input.format,
|
||||||
|
skill_format: mapRrssFormatToSkillFormat(input.format),
|
||||||
|
...aspects,
|
||||||
|
variants_requested: Number(input.variantsRequested || 2),
|
||||||
|
language: 'es',
|
||||||
|
title: input.title,
|
||||||
|
caption: input.caption || null,
|
||||||
|
pillar: input.pillar || cal.pillar || null,
|
||||||
|
month: input.month || null,
|
||||||
|
scheduled_at: input.scheduledAt || null,
|
||||||
|
calendar_piece: {
|
||||||
|
id: input.calendarPieceId || cal.id || null,
|
||||||
|
title: cal.title || input.title,
|
||||||
|
date: cal.date || null,
|
||||||
|
time: cal.time || null,
|
||||||
|
format: cal.format || input.format,
|
||||||
|
pillar: cal.pillar || input.pillar || null,
|
||||||
|
hook: cal.hook || null,
|
||||||
|
body: cal.body || input.caption || null,
|
||||||
|
cta: cal.cta || null,
|
||||||
|
hashtags: cal.hashtags || null,
|
||||||
|
platform: cal.platform || 'instagram',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
brandbook_prompt: input.brandbookPrompt || null,
|
||||||
|
recent_pipeline: (input.recentPipeline || []).slice(0, 12),
|
||||||
|
social_history: input.socialHistory || null,
|
||||||
|
},
|
||||||
|
notes:
|
||||||
|
[
|
||||||
|
input.extraNotes,
|
||||||
|
input.notes,
|
||||||
|
'Canal RRSS · producir pieza para creative_pipeline (no Meta Ads).',
|
||||||
|
'Escribir resultado vía POST /api/integrations/creative-pipeline con generation_request_id.',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
write_back: {
|
||||||
|
target: 'creative_pipeline',
|
||||||
|
pipeline_id: input.pipelineId,
|
||||||
|
endpoint: '/api/integrations/creative-pipeline',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marker stored in pipeline.notes while auto-generation is pending. */
|
||||||
|
export function generationRequestNoteMarker(grId: string): string {
|
||||||
|
return `[[generation_request:${grId}]]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGenerationRequestIdFromNotes(
|
||||||
|
notes: string | null | undefined
|
||||||
|
): string | null {
|
||||||
|
if (!notes) return null;
|
||||||
|
const m = notes.match(/\[\[generation_request:([0-9a-f-]{36})\]\]/i);
|
||||||
|
return m?.[1] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stripGenerationRequestMarker(notes: string | null | undefined): string {
|
||||||
|
if (!notes) return '';
|
||||||
|
return notes
|
||||||
|
.replace(/\[\[generation_request:[0-9a-f-]{36}\]\]/gi, '')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user