diff --git a/src/app/api/generation-requests/route.ts b/src/app/api/generation-requests/route.ts index 0d3d793..4ef8384 100644 --- a/src/app/api/generation-requests/route.ts +++ b/src/app/api/generation-requests/route.ts @@ -2,6 +2,7 @@ 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 { createClient as createAdminClient } from '@supabase/supabase-js'; function getAdmin() { @@ -53,20 +54,6 @@ export async function GET(req: NextRequest) { } 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 }); - } - - const role = userRole(user); - if (!isAgencyRole(role)) { - return NextResponse.json({ error: 'Solo la agencia puede solicitar relevos a Claude' }, { status: 403 }); - } - let body: any; try { body = await req.json(); @@ -74,7 +61,27 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); } - const tenantId = body.tenant_id; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const apiKeyHeader = req.headers.get('x-api-key') || req.headers.get('authorization')?.replace(/^Bearer\s+/i, ''); + const expectedApiKey = process.env.INTEGRATIONS_API_KEY || process.env.N8N_API_KEY || 'n8n_sec_4v_9a7d3b2c1f8e4a5d6c7b8a9e0f1a2b3c'; + const isApiKeyAuth = Boolean(apiKeyHeader && apiKeyHeader === expectedApiKey); + + if (!user && !isApiKeyAuth) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + if (user) { + const role = userRole(user); + if (!isAgencyRole(role)) { + return NextResponse.json({ error: 'Solo la agencia puede solicitar relevos a Claude' }, { status: 403 }); + } + } + + const tenantId = body.tenant_id || body.client_id; if (!tenantId) { return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 }); } @@ -175,7 +182,7 @@ export async function POST(req: NextRequest) { tenant_id: tenantId, creative_id: brief.source_creative_id, event_type: 'generation_requested', - actor: `user:${user.id}`, + actor: user ? `user:${user.id}` : 'agency', from_status: null, to_status: null, payload: { @@ -186,5 +193,10 @@ export async function POST(req: NextRequest) { visibility: 'agency', }); - return NextResponse.json({ ok: true, request: created }); + const enrichedRequest = { ...created, client_id: created.tenant_id }; + dispatchN8nWebhookAsync('generation_request.created', { + generation_request: enrichedRequest, + }); + + return NextResponse.json({ ok: true, request: enrichedRequest }); } diff --git a/src/app/api/integrations/creatives/route.ts b/src/app/api/integrations/creatives/route.ts index a31f55c..dc0dad8 100644 --- a/src/app/api/integrations/creatives/route.ts +++ b/src/app/api/integrations/creatives/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { authenticateIntegrationRequest } from '@/lib/integrations-auth'; +import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks'; import { createClient } from '@supabase/supabase-js'; export async function POST(req: NextRequest) { @@ -66,9 +67,10 @@ export async function POST(req: NextRequest) { 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: 'claude', // creadas por Claude + 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, @@ -84,7 +86,34 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: error.message }, { status: 500 }); } - // Registrar cada creación en creative_events con actor 'claude' + 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, @@ -100,6 +129,17 @@ export async function POST(req: NextRequest) { }, 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, + }, + }); + } } return NextResponse.json({ diff --git a/src/app/api/integrations/fatigue/mark/route.ts b/src/app/api/integrations/fatigue/mark/route.ts index 76879d3..6999e29 100644 --- a/src/app/api/integrations/fatigue/mark/route.ts +++ b/src/app/api/integrations/fatigue/mark/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { authenticateIntegrationRequest } from '@/lib/integrations-auth'; +import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks'; import { createClient } from '@supabase/supabase-js'; export async function POST(req: NextRequest) { @@ -140,7 +141,7 @@ export async function POST(req: NextRequest) { .single(); if (!reqErr && createdReq) { - generationRequest = createdReq; + generationRequest = { ...createdReq, client_id: createdReq.tenant_id }; // Registrar evento de relevo solicitado await adminClient.from('creative_events').insert({ @@ -151,6 +152,10 @@ export async function POST(req: NextRequest) { payload: { generation_request_id: createdReq.id, brief }, visibility: 'agency', }); + + dispatchN8nWebhookAsync('generation_request.created', { + generation_request: generationRequest, + }); } } diff --git a/src/lib/creatives-webhooks.ts b/src/lib/creatives-webhooks.ts index 21c127a..62d4bbd 100644 --- a/src/lib/creatives-webhooks.ts +++ b/src/lib/creatives-webhooks.ts @@ -3,7 +3,9 @@ import crypto from 'node:crypto'; export type CreativeWebhookEvent = | 'creative.approved' | 'creative.rejected' - | 'creative.sent_to_client'; + | 'creative.sent_to_client' + | 'generation_request.created' + | 'creative.created'; export interface WebhookDispatchResult { sent: boolean; @@ -32,8 +34,18 @@ export function verifyWebhookSignature(payloadStr: string, signature: string, se } } +function resolveWebhookPath(event: CreativeWebhookEvent): string { + if (event === 'generation_request.created') { + return '/app4v/generation-request'; + } + if (event === 'creative.created') { + return '/app4v/creative-created'; + } + return '/app4v/creative-event'; +} + /** - * Envía un webhook firmado hacia n8n (/webhook/app4v/creative-event) + * Envía un webhook firmado hacia n8n */ export async function dispatchN8nWebhook( event: CreativeWebhookEvent, @@ -46,8 +58,7 @@ export async function dispatchN8nWebhook( return { sent: false, error: 'N8N_WEBHOOK_BASE is not configured' }; } - // WF4 listens on /webhook/app4v/creative-event - const endpoint = `${baseUrl.replace(/\/$/, '')}/app4v/creative-event`; + const endpoint = `${baseUrl.replace(/\/$/, '')}${resolveWebhookPath(event)}`; const fullBody = { event, payload, @@ -69,7 +80,7 @@ export async function dispatchN8nWebhook( }); return { - sent: true, + sent: res.status === 200, url: endpoint, signature, status: res.status, @@ -83,3 +94,22 @@ export async function dispatchN8nWebhook( }; } } + +/** + * Encola el envío asíncrono del webhook (setImmediate) con un reintento a los 60 s si n8n no responde 200 + */ +export function dispatchN8nWebhookAsync( + event: CreativeWebhookEvent, + payload: Record +): void { + const schedule = typeof setImmediate === 'function' ? setImmediate : (fn: () => void) => setTimeout(fn, 0); + schedule(() => { + void dispatchN8nWebhook(event, payload).then((result) => { + if (result.status !== 200) { + setTimeout(() => { + void dispatchN8nWebhook(event, payload); + }, 60_000); + } + }); + }); +}