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:
4Ventures
2026-10-14 16:21:24 +02:00
parent 0c1ff3cc67
commit 9d7d799ec1
11 changed files with 1085 additions and 112 deletions

View 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.',
});
}

View File

@@ -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', {

View 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 });
}

View File

@@ -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