Files
4ventures-client-portal/src/app/api/integrations/creative-pipeline/route.ts
4Ventures 9d7d799ec1 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>
2026-10-14 16:22:01 +02:00

241 lines
6.3 KiB
TypeScript

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