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>
241 lines
6.3 KiB
TypeScript
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 });
|
|
}
|