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 { createClient } from '@/lib/supabase/server';
|
||||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||||
|
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
function getAdmin() {
|
function getAdmin() {
|
||||||
@@ -53,20 +54,6 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(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;
|
let body: any;
|
||||||
try {
|
try {
|
||||||
body = await req.json();
|
body = await req.json();
|
||||||
@@ -74,7 +61,27 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
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) {
|
if (!tenantId) {
|
||||||
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
|
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -175,7 +182,7 @@ export async function POST(req: NextRequest) {
|
|||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
creative_id: brief.source_creative_id,
|
creative_id: brief.source_creative_id,
|
||||||
event_type: 'generation_requested',
|
event_type: 'generation_requested',
|
||||||
actor: `user:${user.id}`,
|
actor: user ? `user:${user.id}` : 'agency',
|
||||||
from_status: null,
|
from_status: null,
|
||||||
to_status: null,
|
to_status: null,
|
||||||
payload: {
|
payload: {
|
||||||
@@ -186,5 +193,10 @@ export async function POST(req: NextRequest) {
|
|||||||
visibility: 'agency',
|
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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
||||||
|
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -66,9 +67,10 @@ export async function POST(req: NextRequest) {
|
|||||||
cta: c.cta || null,
|
cta: c.cta || null,
|
||||||
asset_url: c.asset_url || null,
|
asset_url: c.asset_url || null,
|
||||||
thumbnail_url: c.thumbnail_url || null,
|
thumbnail_url: c.thumbnail_url || null,
|
||||||
|
asset_final: Boolean(c.asset_final ?? false),
|
||||||
status: 'borrador', // nacen siempre en borrador
|
status: 'borrador', // nacen siempre en borrador
|
||||||
visibility: 'agency', // nacen siempre en agency
|
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,
|
parent_id: c.parent_id || defaultParentId,
|
||||||
generation_request_id: c.generation_request_id || genRequestId || null,
|
generation_request_id: c.generation_request_id || genRequestId || null,
|
||||||
meta,
|
meta,
|
||||||
@@ -84,7 +86,34 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
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 || []) {
|
for (const c of inserted || []) {
|
||||||
await adminClient.from('creative_events').insert({
|
await adminClient.from('creative_events').insert({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
@@ -100,6 +129,17 @@ export async function POST(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
visibility: 'agency',
|
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({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
||||||
|
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||||
import { createClient } from '@supabase/supabase-js';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -140,7 +141,7 @@ export async function POST(req: NextRequest) {
|
|||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!reqErr && createdReq) {
|
if (!reqErr && createdReq) {
|
||||||
generationRequest = createdReq;
|
generationRequest = { ...createdReq, client_id: createdReq.tenant_id };
|
||||||
|
|
||||||
// Registrar evento de relevo solicitado
|
// Registrar evento de relevo solicitado
|
||||||
await adminClient.from('creative_events').insert({
|
await adminClient.from('creative_events').insert({
|
||||||
@@ -151,6 +152,10 @@ export async function POST(req: NextRequest) {
|
|||||||
payload: { generation_request_id: createdReq.id, brief },
|
payload: { generation_request_id: createdReq.id, brief },
|
||||||
visibility: 'agency',
|
visibility: 'agency',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
dispatchN8nWebhookAsync('generation_request.created', {
|
||||||
|
generation_request: generationRequest,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import crypto from 'node:crypto';
|
|||||||
export type CreativeWebhookEvent =
|
export type CreativeWebhookEvent =
|
||||||
| 'creative.approved'
|
| 'creative.approved'
|
||||||
| 'creative.rejected'
|
| 'creative.rejected'
|
||||||
| 'creative.sent_to_client';
|
| 'creative.sent_to_client'
|
||||||
|
| 'generation_request.created'
|
||||||
|
| 'creative.created';
|
||||||
|
|
||||||
export interface WebhookDispatchResult {
|
export interface WebhookDispatchResult {
|
||||||
sent: boolean;
|
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(
|
export async function dispatchN8nWebhook(
|
||||||
event: CreativeWebhookEvent,
|
event: CreativeWebhookEvent,
|
||||||
@@ -46,8 +58,7 @@ export async function dispatchN8nWebhook(
|
|||||||
return { sent: false, error: 'N8N_WEBHOOK_BASE is not configured' };
|
return { sent: false, error: 'N8N_WEBHOOK_BASE is not configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// WF4 listens on /webhook/app4v/creative-event
|
const endpoint = `${baseUrl.replace(/\/$/, '')}${resolveWebhookPath(event)}`;
|
||||||
const endpoint = `${baseUrl.replace(/\/$/, '')}/app4v/creative-event`;
|
|
||||||
const fullBody = {
|
const fullBody = {
|
||||||
event,
|
event,
|
||||||
payload,
|
payload,
|
||||||
@@ -69,7 +80,7 @@ export async function dispatchN8nWebhook(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sent: true,
|
sent: res.status === 200,
|
||||||
url: endpoint,
|
url: endpoint,
|
||||||
signature,
|
signature,
|
||||||
status: res.status,
|
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