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