Files
4ventures-client-portal/src/app/api/integrations/creatives/route.ts
4Ventures 1cf8e7c1bd Require 9:16 and 1.91:1 adaptations alongside every static 4:5 ad creative.
Generation briefs and Canva webhooks now declare all three Meta placements so WF3/WF5 deliver feed, Stories, and landscape from one Freepik base.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 17:03:40 +02:00

279 lines
8.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import { staticAdWebhookAspectFields } from '@/lib/creatives-aspect-ratios';
import { normalizeMetaAdCopyInput } from '@/lib/meta-ad-copy';
import { createClient } from '@supabase/supabase-js';
export async function POST(req: NextRequest) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const auth = await authenticateIntegrationRequest(req, { requireTenant: true, parsedBody: body });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
// Acepta array o un único objeto
const items = Array.isArray(body)
? body
: Array.isArray(body.creatives)
? body.creatives
: [body];
if (!items.length) {
return NextResponse.json({ error: 'No creatives provided' }, { status: 400 });
}
// Si viene generation_request_id, averiguar parent_id (source_creative_id del request)
let defaultParentId: string | null = null;
const genRequestId = body.generation_request_id || items[0]?.generation_request_id;
if (genRequestId) {
const { data: genReq } = await adminClient
.from('generation_requests')
.select('source_creative_id')
.eq('id', genRequestId)
.eq('tenant_id', tenantId)
.maybeSingle();
if (genReq) {
defaultParentId = genReq.source_creative_id;
}
}
const toInsert = items.map((c: any) => {
const copyNorm = normalizeMetaAdCopyInput({
primary_text: c.primary_text,
primary_texts: c.primary_texts,
headline: c.headline,
headlines: c.headlines,
description: c.description,
hook: c.hook,
support: c.support,
cta: c.cta,
meta: c.meta || {},
});
const meta = { ...copyNorm.meta };
if (c.composition_notes) {
meta.composition_notes = c.composition_notes;
}
if (c.composition_notes_by_aspect) {
meta.composition_notes_by_aspect = c.composition_notes_by_aspect;
}
if (c.stories_asset_url) meta.stories_asset_url = c.stories_asset_url;
if (c.landscape_asset_url) meta.landscape_asset_url = c.landscape_asset_url;
if (c.asset_url && !c.asset_final && !meta.raw_image_url) {
meta.raw_image_url = c.asset_url;
}
return {
tenant_id: tenantId,
campaign_name: c.campaign_name || null,
adset_id: c.adset_id || null,
meta_ad_id: c.meta_ad_id || null,
format: c.format || 'estatico',
angle: c.angle || null,
hook: copyNorm.hook || c.hook || null,
primary_text: copyNorm.primary_text,
headline: copyNorm.headline,
description: copyNorm.description,
cta: c.cta || null,
asset_url: c.asset_url || null,
thumbnail_url: c.thumbnail_url || null,
asset_final: Boolean(c.asset_final ?? false),
status: 'borrador', // nacen siempre en borrador
visibility: 'agency', // nacen siempre en agency
created_by: c.created_by || 'claude', // creadas por Claude
parent_id: c.parent_id || defaultParentId,
generation_request_id: c.generation_request_id || genRequestId || null,
meta,
};
});
const { data: inserted, error } = await adminClient
.from('creatives')
.insert(toInsert)
.select('*');
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const { data: tenant } = await adminClient
.from('tenants')
.select('id, name, slug, drive_folder_id, canva_templates')
.eq('id', tenantId)
.maybeSingle();
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
let canvaTemplates = tenant?.canva_templates || {};
if (
tenant?.slug === 'bps' &&
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
const clientInfo = {
id: tenant?.id || tenantId,
name: tenant?.name || null,
slug: tenant?.slug || 'bps',
drive_folder_id: tenant?.drive_folder_id || null,
canva_templates: canvaTemplates,
};
// Registrar cada creación en creative_events con actor 'claude' y disparar creative.created si created_by='claude' y asset_final=false
for (const c of inserted || []) {
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: c.id,
event_type: 'creative_generated',
actor: 'claude',
to_status: 'borrador',
payload: {
angle: c.angle,
format: c.format,
generation_request_id: c.generation_request_id,
parent_id: c.parent_id,
},
visibility: 'agency',
});
if (c.created_by === 'claude' && c.asset_final === false) {
dispatchN8nWebhookAsync('creative.created', {
creative: {
...c,
client_id: c.tenant_id,
meta: c.meta || {},
client: clientInfo,
},
...staticAdWebhookAspectFields(c.format),
});
}
}
return NextResponse.json({
ok: true,
count: inserted?.length || 0,
creatives: inserted,
});
}
export async function GET(req: NextRequest) {
const auth = await authenticateIntegrationRequest(req, { requireTenant: false });
if (!auth.ok) {
return auth.errorResponse!;
}
const searchParams = req.nextUrl.searchParams;
const tenantId = searchParams.get('tenant_id') || searchParams.get('client_id');
const status = searchParams.get('status');
const assetFinal = searchParams.get('asset_final');
const createdBy = searchParams.get('created_by');
const include = searchParams.get('include');
const limitParam = searchParams.get('limit');
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
let query = adminClient
.from('creatives')
.select('*, tenant:tenants(id, name, slug, canva_templates, drive_folder_id, creatives_enabled)');
if (tenantId) {
query = query.eq('tenant_id', tenantId);
}
if (status) {
query = query.eq('status', status);
}
if (assetFinal === 'true') {
query = query.eq('asset_final', true);
} else if (assetFinal === 'false') {
query = query.eq('asset_final', false);
}
if (createdBy) {
query = query.eq('created_by', createdBy);
}
query = query.order('created_at', { ascending: true });
if (limitParam) {
const parsedLimit = Number(limitParam);
if (!isNaN(parsedLimit) && parsedLimit > 0) {
query = query.limit(parsedLimit);
}
}
const { data: rows, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
const enriched = (rows || [])
.filter((row: any) => {
if (tenantId) return true;
return row.tenant?.creatives_enabled !== false;
})
.map((row: any) => {
const { tenant, ...rest } = row;
let canvaTemplates = tenant?.canva_templates || {};
if (
tenant?.slug === 'bps' &&
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
) {
canvaTemplates = {
'foto-con-franja': defaultBpsTemplate,
tipografico: null,
default: defaultBpsTemplate,
};
}
const item: Record<string, any> = {
...rest,
client_id: rest.tenant_id,
meta: rest.meta || {},
};
if (include === 'client') {
item.client = tenant
? {
id: tenant.id,
name: tenant.name,
slug: tenant.slug,
canva_templates: canvaTemplates,
drive_folder_id: tenant.drive_folder_id || null,
}
: null;
}
return item;
});
return NextResponse.json({
ok: true,
count: enriched.length,
data: enriched,
creatives: enriched,
});
}