feat(creatives): task 5 - approval actions, outgoing webhooks with HMAC, and publish patch
This commit is contained in:
79
src/lib/creatives-webhooks.test.ts
Normal file
79
src/lib/creatives-webhooks.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
84
src/lib/creatives-webhooks.ts
Normal file
84
src/lib/creatives-webhooks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
286
src/lib/creatives-workflow.ts
Normal file
286
src/lib/creatives-workflow.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user