feat(creatives): task 5 - approval actions, outgoing webhooks with HMAC, and publish patch
This commit is contained in:
77
src/app/api/creatives/[id]/action/route.ts
Normal file
77
src/app/api/creatives/[id]/action/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
82
src/app/api/integrations/creatives/[id]/route.ts
Normal file
82
src/app/api/integrations/creatives/[id]/route.ts
Normal 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);
|
||||
}
|
||||
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