feat(creatives): task 5 - approval actions, outgoing webhooks with HMAC, and publish patch

This commit is contained in:
Marcos Fargas
2026-10-12 13:44:54 +02:00
parent 7b702cd4cc
commit 958c59a8ec
5 changed files with 608 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { userRole } from '@/lib/agency';
import {
sendCreativeToClient,
approveCreative,
rejectCreative,
retireCreative,
} from '@/lib/creatives-workflow';
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const ctx = await resolveActiveTenant(user);
const tenantId = ctx.tenantId;
if (!tenantId) {
return NextResponse.json(
{ error: 'Tenant context required' },
{ status: 400 }
);
}
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire'
const role = userRole(user);
const actor = { role, userId: user.id };
try {
let result: any;
switch (action) {
case 'send_to_client':
result = await sendCreativeToClient(id, tenantId, actor);
break;
case 'approve':
result = await approveCreative(id, tenantId, actor, body.comment);
break;
case 'reject':
result = await rejectCreative(id, tenantId, actor, body.comment);
break;
case 'retire':
result = await retireCreative(id, tenantId, actor, body.reason);
break;
default:
return NextResponse.json(
{ error: `Acción desconocida: ${action}` },
{ status: 400 }
);
}
return NextResponse.json({ ok: true, creative: result });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
}

View File

@@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { id } = await params;
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);
// Obtener creatividad previa
const { data: creative, error: getErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', id)
.eq('tenant_id', tenantId)
.maybeSingle();
if (getErr || !creative) {
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
}
const prevStatus = creative.status;
const updates: Record<string, any> = {
updated_at: new Date().toISOString(),
};
if (body.meta_ad_id !== undefined) updates.meta_ad_id = body.meta_ad_id;
if (body.status !== undefined) updates.status = body.status;
if (body.adset_id !== undefined) updates.adset_id = body.adset_id;
if (body.campaign_name !== undefined) updates.campaign_name = body.campaign_name;
if (body.asset_url !== undefined) updates.asset_url = body.asset_url;
if (body.thumbnail_url !== undefined) updates.thumbnail_url = body.thumbnail_url;
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update(updates)
.eq('id', id)
.eq('tenant_id', tenantId)
.select('*')
.single();
if (updateErr) {
return NextResponse.json({ error: updateErr.message }, { status: 500 });
}
// Si pasa a publicada o cambia meta_ad_id, registrar en creative_events
if (updates.status === 'publicada' || updates.meta_ad_id) {
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: id,
event_type: updates.status === 'publicada' ? 'published' : 'creative_updated',
actor: auth.caller || 'n8n',
from_status: prevStatus,
to_status: updates.status || prevStatus,
payload: {
meta_ad_id: updates.meta_ad_id || creative.meta_ad_id,
adset_id: updates.adset_id,
},
visibility: 'client',
});
}
return NextResponse.json(updated);
}

View File

@@ -0,0 +1,79 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import http from 'node:http';
import {
signWebhookPayload,
verifyWebhookSignature,
dispatchN8nWebhook,
} from './creatives-webhooks';
describe('Creatives Outbound Webhook & HMAC Signature', () => {
const secret = 'whsec_4v_test_secret_123456789';
const payload = JSON.stringify({
event: 'creative.approved',
timestamp: '2026-10-12T10:00:00.000Z',
data: {
client_id: '8bec977a-6032-4759-aa7f-b5042081f450',
creative_id: '1800fa1e-e969-469c-9913-6c973acba346',
approved_by_role: 'client',
comment: 'Aprobado para lanzar mañana.',
},
});
it('signs payload and verifies valid HMAC-SHA256 signature', () => {
const signature = signWebhookPayload(payload, secret);
assert.ok(signature && signature.length === 64, 'HMAC hex signature must be 64 characters');
const isValid = verifyWebhookSignature(payload, signature, secret);
assert.strictEqual(isValid, true, 'Signature must verify against same secret');
});
it('rejects tampered payload or incorrect secret', () => {
const signature = signWebhookPayload(payload, secret);
const tampered = payload.replace('lanzar mañana', 'no lanzar');
assert.strictEqual(verifyWebhookSignature(tampered, signature, secret), false);
assert.strictEqual(verifyWebhookSignature(payload, signature, 'wrong-secret'), false);
});
it('dispatches HTTP POST with X-Signature header to a test server and verifies signature', async () => {
let receivedHeader: string | null = null;
let receivedBody = '';
// Iniciar servidor HTTP local para verificar el webhook firmado
const server = http.createServer((req, res) => {
receivedHeader = req.headers['x-signature'] as string;
req.on('data', (chunk) => {
receivedBody += chunk;
});
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
const port = (server.address() as any).port;
const testUrl = `http://127.0.0.1:${port}/webhook`;
process.env.N8N_WEBHOOK_BASE = testUrl;
process.env.N8N_WEBHOOK_SECRET = secret;
try {
const result = await dispatchN8nWebhook('creative.approved', {
client_id: '8bec977a-6032-4759-aa7f-b5042081f450',
creative_id: '1800fa1e-e969-469c-9913-6c973acba346',
approved_by_role: 'agency',
comment: 'Validado por la agencia',
});
assert.strictEqual(result.sent, true);
assert.strictEqual(result.status, 200);
assert.ok(receivedHeader, 'X-Signature header must be received');
// Comprobar la firma recibida en el servidor con el body recibido y el secreto
const verified = verifyWebhookSignature(receivedBody, receivedHeader!, secret);
assert.strictEqual(verified, true, 'Server must verify HMAC signature with shared secret');
} finally {
server.close();
}
});
});

View File

@@ -0,0 +1,84 @@
import crypto from 'node:crypto';
export type CreativeWebhookEvent =
| 'creative.approved'
| 'creative.rejected'
| 'creative.sent_to_client';
export interface WebhookDispatchResult {
sent: boolean;
url?: string;
signature?: string;
status?: number;
error?: string;
}
/**
* Calcula la firma HMAC-SHA256 para el payload del webhook
*/
export function signWebhookPayload(payloadStr: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(payloadStr).digest('hex');
}
/**
* Verifica una firma HMAC-SHA256
*/
export function verifyWebhookSignature(payloadStr: string, signature: string, secret: string): boolean {
try {
const expected = signWebhookPayload(payloadStr, secret);
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
} catch {
return false;
}
}
/**
* Envía un webhook firmado hacia n8n (N8N_WEBHOOK_BASE/event)
*/
export async function dispatchN8nWebhook(
event: CreativeWebhookEvent,
payload: Record<string, unknown>
): Promise<WebhookDispatchResult> {
const baseUrl = process.env.N8N_WEBHOOK_BASE?.trim() || 'https://n8n-agencia.4ventures.es/webhook';
const secret = process.env.N8N_WEBHOOK_SECRET?.trim() || 'whsec_4v_7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
if (!baseUrl) {
return { sent: false, error: 'N8N_WEBHOOK_BASE is not configured' };
}
const endpoint = `${baseUrl.replace(/\/$/, '')}/${event.replace('.', '-')}`;
const fullBody = {
event,
timestamp: new Date().toISOString(),
data: payload,
};
const bodyStr = JSON.stringify(fullBody);
const signature = signWebhookPayload(bodyStr, secret);
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': signature,
'User-Agent': '4Ventures-App-Webhook/1.0',
},
body: bodyStr,
});
return {
sent: true,
url: endpoint,
signature,
status: res.status,
};
} catch (err: any) {
return {
sent: false,
url: endpoint,
signature,
error: err.message,
};
}
}

View File

@@ -0,0 +1,286 @@
import { createClient } from '@supabase/supabase-js';
import { dispatchN8nWebhook } from './creatives-webhooks';
import { normalizeRole } from './creatives-visibility';
export interface ActionActor {
role: 'agency' | 'client' | string;
userId?: string | null;
}
function getAdminClient() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createClient(supabaseUrl, serviceRoleKey);
}
/**
* 1. Enviar al cliente
* Cambia visibility a 'client' y status a 'pendiente_aprobacion'
* Dispara webhook 'creative.sent_to_client'
*/
export async function sendCreativeToClient(creativeId: string, tenantId: string, actor: ActionActor) {
const adminClient = getAdminClient();
const { data: creative, error: getErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', creativeId)
.eq('tenant_id', tenantId)
.single();
if (getErr || !creative) {
throw new Error('Creatividad no encontrada');
}
const prevStatus = creative.status;
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update({
visibility: 'client',
status: 'pendiente_aprobacion',
updated_at: new Date().toISOString(),
})
.eq('id', creativeId)
.select('*')
.single();
if (updateErr) {
throw new Error(updateErr.message);
}
// Registrar en creative_events
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: creativeId,
event_type: 'status_change',
actor: actor.userId ? `user:${actor.userId}` : 'agency',
from_status: prevStatus,
to_status: 'pendiente_aprobacion',
payload: { action: 'sent_to_client' },
visibility: 'client',
});
// Disparar webhook saliente a n8n
await dispatchN8nWebhook('creative.sent_to_client', {
client_id: tenantId,
tenant_id: tenantId,
creative_id: creativeId,
});
return updated;
}
/**
* 2. Aprobar creatividad
* Cambia status a 'aprobada'
* Escribe en approvals y creative_events
* Dispara webhook 'creative.approved'
*/
export async function approveCreative(
creativeId: string,
tenantId: string,
actor: ActionActor,
comment?: string | null
) {
const adminClient = getAdminClient();
const normRole = normalizeRole(actor.role);
const { data: creative, error: getErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', creativeId)
.eq('tenant_id', tenantId)
.single();
if (getErr || !creative) {
throw new Error('Creatividad no encontrada');
}
const prevStatus = creative.status;
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update({
status: 'aprobada',
updated_at: new Date().toISOString(),
})
.eq('id', creativeId)
.select('*')
.single();
if (updateErr) {
throw new Error(updateErr.message);
}
// Registrar en approvals
await adminClient.from('approvals').insert({
tenant_id: tenantId,
creative_id: creativeId,
user_id: actor.userId || null,
actor_role: normRole,
decision: 'aprobada',
comment: comment || null,
channel: 'app',
});
// Registrar en creative_events
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: creativeId,
event_type: 'status_change',
actor: actor.userId ? `user:${actor.userId}` : normRole,
from_status: prevStatus,
to_status: 'aprobada',
payload: { comment },
visibility: 'client',
});
// Disparar webhook saliente a n8n
await dispatchN8nWebhook('creative.approved', {
client_id: tenantId,
tenant_id: tenantId,
creative_id: creativeId,
approved_by_role: normRole,
comment: comment || null,
});
return updated;
}
/**
* 3. Rechazar creatividad
* Comentario obligatorio
* Cambia status a 'rechazada'
* Escribe en approvals y creative_events
* Dispara webhook 'creative.rejected'
*/
export async function rejectCreative(
creativeId: string,
tenantId: string,
actor: ActionActor,
comment: string
) {
if (!comment || !comment.trim()) {
throw new Error('El comentario es obligatorio al rechazar una creatividad');
}
const adminClient = getAdminClient();
const normRole = normalizeRole(actor.role);
const { data: creative, error: getErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', creativeId)
.eq('tenant_id', tenantId)
.single();
if (getErr || !creative) {
throw new Error('Creatividad no encontrada');
}
const prevStatus = creative.status;
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update({
status: 'rechazada',
updated_at: new Date().toISOString(),
})
.eq('id', creativeId)
.select('*')
.single();
if (updateErr) {
throw new Error(updateErr.message);
}
// Registrar en approvals
await adminClient.from('approvals').insert({
tenant_id: tenantId,
creative_id: creativeId,
user_id: actor.userId || null,
actor_role: normRole,
decision: 'rechazada',
comment: comment.trim(),
channel: 'app',
});
// Registrar en creative_events
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: creativeId,
event_type: 'status_change',
actor: actor.userId ? `user:${actor.userId}` : normRole,
from_status: prevStatus,
to_status: 'rechazada',
payload: { comment: comment.trim() },
visibility: 'agency',
});
// Disparar webhook saliente a n8n
await dispatchN8nWebhook('creative.rejected', {
client_id: tenantId,
tenant_id: tenantId,
creative_id: creativeId,
rejected_by_role: normRole,
comment: comment.trim(),
});
return updated;
}
/**
* 4. Retirar creatividad
* Cambia status a 'retirada'
*/
export async function retireCreative(
creativeId: string,
tenantId: string,
actor: ActionActor,
reason?: string
) {
const adminClient = getAdminClient();
const normRole = normalizeRole(actor.role);
const { data: creative, error: getErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', creativeId)
.eq('tenant_id', tenantId)
.single();
if (getErr || !creative) {
throw new Error('Creatividad no encontrada');
}
const prevStatus = creative.status;
const { data: updated, error: updateErr } = await adminClient
.from('creatives')
.update({
status: 'retirada',
updated_at: new Date().toISOString(),
})
.eq('id', creativeId)
.select('*')
.single();
if (updateErr) {
throw new Error(updateErr.message);
}
// Registrar en creative_events
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: creativeId,
event_type: 'status_change',
actor: actor.userId ? `user:${actor.userId}` : normRole,
from_status: prevStatus,
to_status: 'retirada',
payload: { reason: reason || null },
visibility: 'client',
});
return updated;
}