feat(webhooks): dispatch async generation_request.created and creative.created events to n8n with 60s retry

This commit is contained in:
Marcos Fargas
2026-10-13 01:17:26 +02:00
parent 66d34853e6
commit 2a1629cda8
4 changed files with 112 additions and 25 deletions

View File

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

View File

@@ -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({

View File

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

View File

@@ -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<string, unknown>
): 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);
}
});
});
}