feat(webhooks): dispatch async generation_request.created and creative.created events to n8n with 60s retry
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user