diff --git a/src/app/api/creatives/[id]/action/route.ts b/src/app/api/creatives/[id]/action/route.ts new file mode 100644 index 0000000..45ba2c3 --- /dev/null +++ b/src/app/api/creatives/[id]/action/route.ts @@ -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 }); + } +} diff --git a/src/app/api/integrations/creatives/[id]/route.ts b/src/app/api/integrations/creatives/[id]/route.ts new file mode 100644 index 0000000..00a2365 --- /dev/null +++ b/src/app/api/integrations/creatives/[id]/route.ts @@ -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 = { + 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); +} diff --git a/src/lib/creatives-webhooks.test.ts b/src/lib/creatives-webhooks.test.ts new file mode 100644 index 0000000..5f3e3d6 --- /dev/null +++ b/src/lib/creatives-webhooks.test.ts @@ -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((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(); + } + }); +}); diff --git a/src/lib/creatives-webhooks.ts b/src/lib/creatives-webhooks.ts new file mode 100644 index 0000000..79e908c --- /dev/null +++ b/src/lib/creatives-webhooks.ts @@ -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 +): Promise { + 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, + }; + } +} diff --git a/src/lib/creatives-workflow.ts b/src/lib/creatives-workflow.ts new file mode 100644 index 0000000..6c110cc --- /dev/null +++ b/src/lib/creatives-workflow.ts @@ -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; +}