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