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 { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||
import { staticAdBriefAspectFields } from '@/lib/creatives-aspect-ratios';
|
||||
import {
|
||||
isRrssPipelineFormat,
|
||||
rrssBriefAspectFields,
|
||||
} from '@/lib/rrss-generation';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
|
||||
function getAdmin() {
|
||||
@@ -145,23 +149,48 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
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 = {
|
||||
tenant_id: tenantId,
|
||||
client_id: tenantId,
|
||||
client_slug: tenant?.slug || 'cliente',
|
||||
reason: body.reason || 'manual',
|
||||
source_creative_id: body.source_creative_id || null,
|
||||
client_name: tenant?.name || 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,
|
||||
...staticAdBriefAspectFields(format),
|
||||
variants_requested: Number(body.variants_requested || 3),
|
||||
campaign_name: campaignName,
|
||||
adset_id: adsetId,
|
||||
audience,
|
||||
product,
|
||||
landing_url: landingUrl,
|
||||
skill_format: body.skill_format || format,
|
||||
...aspectFields,
|
||||
variants_requested: Number(
|
||||
body.variants_requested || (channel === 'rrss' ? 2 : 3)
|
||||
),
|
||||
campaign_name: channel === 'rrss' ? null : campaignName,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -181,21 +210,23 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Registrar en creative_events
|
||||
await admin.from('creative_events').insert({
|
||||
tenant_id: tenantId,
|
||||
creative_id: brief.source_creative_id,
|
||||
event_type: 'generation_requested',
|
||||
actor: user ? `user:${user.id}` : 'agency',
|
||||
from_status: null,
|
||||
to_status: null,
|
||||
payload: {
|
||||
generation_request_id: created.id,
|
||||
reason: brief.reason,
|
||||
variants_requested: brief.variants_requested,
|
||||
},
|
||||
visibility: 'agency',
|
||||
});
|
||||
// Registrar en creative_events (solo Ads: FK a creatives)
|
||||
if (channel === 'ads' && brief.source_creative_id) {
|
||||
await admin.from('creative_events').insert({
|
||||
tenant_id: tenantId,
|
||||
creative_id: brief.source_creative_id,
|
||||
event_type: 'generation_requested',
|
||||
actor: user ? `user:${user.id}` : 'agency',
|
||||
from_status: null,
|
||||
to_status: null,
|
||||
payload: {
|
||||
generation_request_id: created.id,
|
||||
reason: brief.reason,
|
||||
variants_requested: brief.variants_requested,
|
||||
},
|
||||
visibility: 'agency',
|
||||
});
|
||||
}
|
||||
|
||||
const enrichedRequest = { ...created, client_id: created.tenant_id };
|
||||
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 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)
|
||||
const { data: brandDoc } = await adminClient
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
scheduledAtFromPiece,
|
||||
} from '@/lib/calendar-documents';
|
||||
import { captionHasProductionNoise } from '@/lib/publish-caption';
|
||||
import { parseGenerationRequestIdFromNotes } from '@/lib/rrss-generation';
|
||||
import {
|
||||
AGENCY_STATUS_ORDER,
|
||||
APPROVAL_COLOR,
|
||||
@@ -95,6 +96,7 @@ const FORMAT_CONFIG: Record<string, { label: string; badgeCls: string }> = {
|
||||
|
||||
function mediaUrl(path: string | null) {
|
||||
if (!path) return null;
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
@@ -138,6 +140,8 @@ export default function CreativePipelineBoard() {
|
||||
const [newDate, setNewDate] = useState('');
|
||||
const [newTime, setNewTime] = useState('10:00');
|
||||
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 [coverPreview, setCoverPreview] = useState<string | null>(null);
|
||||
const [slideFiles, setSlideFiles] = useState<File[]>([]);
|
||||
@@ -277,6 +281,7 @@ export default function CreativePipelineBoard() {
|
||||
setNewDate('');
|
||||
setNewTime('10:00');
|
||||
setSyncCalendar(true);
|
||||
setCreationMode('manual');
|
||||
clearMediaSelection();
|
||||
setShowForm(false);
|
||||
}
|
||||
@@ -339,6 +344,10 @@ export default function CreativePipelineBoard() {
|
||||
try {
|
||||
let calendarPieceId: string | null =
|
||||
calPick !== '__new__' ? calPick : null;
|
||||
const pickedCal =
|
||||
calendarPieceId && calendarPieceId !== '__new__'
|
||||
? calPieces.find((p) => p.id === calendarPieceId)
|
||||
: null;
|
||||
|
||||
if (syncCalendar) {
|
||||
const date =
|
||||
@@ -374,6 +383,60 @@ export default function CreativePipelineBoard() {
|
||||
? scheduledAtFromPiece({ date: newDate, time: newTime })
|
||||
: 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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1005,91 +1068,131 @@ export default function CreativePipelineBoard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<label className="block text-xs font-semibold text-gray-700">
|
||||
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.
|
||||
<div className="sm:col-span-2 lg:col-span-3 rounded-xl border border-gray-200 bg-gray-50 p-3 space-y-2">
|
||||
<p className="text-xs font-semibold text-gray-800">
|
||||
Cómo crear el creativo
|
||||
</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>
|
||||
{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 className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreationMode('manual')}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-semibold border ${
|
||||
creationMode === 'manual'
|
||||
? 'bg-gray-900 text-white border-gray-900'
|
||||
: 'bg-white text-gray-700 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
Manual — subo yo la imagen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCreationMode('auto');
|
||||
clearMediaSelection();
|
||||
}}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-semibold border ${
|
||||
creationMode === 'auto'
|
||||
? 'bg-brand-600 text-white border-brand-600'
|
||||
: 'bg-white text-gray-700 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
Auto — Claude + Freepik + Canva
|
||||
</button>
|
||||
</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' ? (
|
||||
<div className="mt-3">
|
||||
<label className="block text-xs font-medium text-gray-600">
|
||||
Slides del carrusel (opcionales)
|
||||
{creationMode === 'manual' ? (
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<label className="block text-xs font-semibold text-gray-700">
|
||||
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>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
) : 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>
|
||||
|
||||
<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"
|
||||
>
|
||||
{creating
|
||||
? coverFile
|
||||
? 'Creando y subiendo creativo…'
|
||||
: 'Creando…'
|
||||
: calPick === '__new__'
|
||||
? 'Crear en pipeline'
|
||||
: 'Añadir al pipeline (desde calendario)'}
|
||||
? creationMode === 'auto'
|
||||
? 'Encolando Claude + Freepik + Canva…'
|
||||
: coverFile
|
||||
? 'Creando y subiendo creativo…'
|
||||
: 'Creando…'
|
||||
: creationMode === 'auto'
|
||||
? 'Crear con IA (RRSS)'
|
||||
: calPick === '__new__'
|
||||
? 'Crear en pipeline'
|
||||
: 'Añadir al pipeline (desde calendario)'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1260,6 +1367,8 @@ function PieceCard({
|
||||
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
};
|
||||
const igUrl = getInstagramUrl(item.notes);
|
||||
const autoGenPending =
|
||||
Boolean(parseGenerationRequestIdFromNotes(item.notes)) && !item.cover_path;
|
||||
|
||||
const status = normalizeApprovalStatus(item.status);
|
||||
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
|
||||
@@ -1331,6 +1440,11 @@ function PieceCard({
|
||||
{slides.length} slides
|
||||
</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>
|
||||
|
||||
{/* Botón de vista rápida */}
|
||||
|
||||
@@ -109,8 +109,14 @@ export interface GenerationRequestBrief {
|
||||
client_slug: string;
|
||||
reason: GenerationRequestReason;
|
||||
source_creative_id?: string | null;
|
||||
format: 'estatico' | 'carrusel' | 'video';
|
||||
/** Primary design ratio (always 4:5 for estático). */
|
||||
/** ads (default) | rrss — RRSS writes back to creative_pipeline */
|
||||
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;
|
||||
/** All placements to produce for estático: 4:5, 9:16, 1.91:1. */
|
||||
aspect_ratios?: string[];
|
||||
@@ -127,6 +133,13 @@ export interface GenerationRequestBrief {
|
||||
adset_id?: string | null;
|
||||
language?: 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 {
|
||||
|
||||
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