feat(creatives): task 4 - claude integration endpoints (generation-requests, creatives, context, events)

This commit is contained in:
Marcos Fargas
2026-10-12 13:35:50 +02:00
parent 9f1721c921
commit 0f9f03fad5
5 changed files with 416 additions and 0 deletions

View File

@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
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);
// 1. Obtener la creatividad (la fatigada u objetivo)
const { data: creative, error: cErr } = await adminClient
.from('creatives')
.select('*')
.eq('id', id)
.eq('tenant_id', tenantId)
.maybeSingle();
if (cErr || !creative) {
return NextResponse.json(
{ error: 'Creative not found' },
{ status: 404 }
);
}
// 2. Obtener métricas de la creatividad
let metrics: any[] = [];
if (creative.meta_ad_id) {
const { data: mData } = await adminClient
.from('creative_metrics_daily')
.select('*')
.eq('tenant_id', tenantId)
.eq('meta_ad_id', creative.meta_ad_id)
.order('date', { ascending: true });
metrics = mData || [];
}
// 3. Reglas de fatiga del cliente
const { data: rules } = await adminClient
.from('fatigue_rules')
.select('*')
.eq('tenant_id', tenantId)
.maybeSingle();
// 4. Obtener las 3 mejores creatividades históricas del cliente por CTR con su angle y hook
// Para calcular el CTR promedio ponderado o histórico de cada creatividad con métricas:
const { data: topCreativesWithMetrics } = await adminClient
.from('creative_metrics_daily')
.select('creative_id, impressions, clicks, spend, results')
.eq('tenant_id', tenantId)
.gt('impressions', 100);
const creativePerformance = new Map<string, { impressions: number; clicks: number }>();
(topCreativesWithMetrics || []).forEach((row: any) => {
const current = creativePerformance.get(row.creative_id) || { impressions: 0, clicks: 0 };
current.impressions += Number(row.impressions) || 0;
current.clicks += Number(row.clicks) || 0;
creativePerformance.set(row.creative_id, current);
});
const rankedIds = Array.from(creativePerformance.entries())
.filter(([_, stats]) => stats.impressions > 0)
.map(([creative_id, stats]) => ({
creative_id,
ctr: (stats.clicks / stats.impressions) * 100,
impressions: stats.impressions,
clicks: stats.clicks,
}))
.sort((a, b) => b.ctr - a.ctr)
.slice(0, 3);
let topCreatives: any[] = [];
if (rankedIds.length > 0) {
const ids = rankedIds.map((r) => r.creative_id);
const { data: topRows } = await adminClient
.from('creatives')
.select('id, format, angle, hook, primary_text, headline, cta, asset_url')
.in('id', ids);
topCreatives = (topRows || []).map((row: any) => {
const stats = rankedIds.find((r) => r.creative_id === row.id);
return {
...row,
historical_ctr: stats ? Number(stats.ctr.toFixed(3)) : null,
impressions: stats?.impressions || 0,
clicks: stats?.clicks || 0,
};
});
}
// Si no hubiera métricas suficientes, buscar las últimas creatividades con angle y hook
if (topCreatives.length === 0) {
const { data: fallbackRows } = await adminClient
.from('creatives')
.select('id, format, angle, hook, primary_text, headline, cta, asset_url')
.eq('tenant_id', tenantId)
.not('angle', 'is', null)
.limit(3);
topCreatives = fallbackRows || [];
}
return NextResponse.json({
client_id: tenantId,
tenant_id: tenantId,
creative,
metrics,
rules: rules || null,
top_historical_creatives: topCreatives,
});
}

View File

@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function POST(req: NextRequest) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
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);
// Acepta array o un único objeto
const items = Array.isArray(body)
? body
: Array.isArray(body.creatives)
? body.creatives
: [body];
if (!items.length) {
return NextResponse.json({ error: 'No creatives provided' }, { status: 400 });
}
// Si viene generation_request_id, averiguar parent_id (source_creative_id del request)
let defaultParentId: string | null = null;
const genRequestId = body.generation_request_id || items[0]?.generation_request_id;
if (genRequestId) {
const { data: genReq } = await adminClient
.from('generation_requests')
.select('source_creative_id')
.eq('id', genRequestId)
.eq('tenant_id', tenantId)
.maybeSingle();
if (genReq) {
defaultParentId = genReq.source_creative_id;
}
}
const toInsert = items.map((c: any) => ({
tenant_id: tenantId,
campaign_name: c.campaign_name || null,
adset_id: c.adset_id || null,
meta_ad_id: c.meta_ad_id || null,
format: c.format || 'estatico',
angle: c.angle || null,
hook: c.hook || null,
primary_text: c.primary_text || null,
headline: c.headline || null,
cta: c.cta || null,
asset_url: c.asset_url || null,
thumbnail_url: c.thumbnail_url || null,
status: 'borrador', // nacen siempre en borrador
visibility: 'agency', // nacen siempre en agency
created_by: 'claude', // creadas por Claude
parent_id: c.parent_id || defaultParentId,
generation_request_id: c.generation_request_id || genRequestId || null,
}));
const { data: inserted, error } = await adminClient
.from('creatives')
.insert(toInsert)
.select('*');
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Registrar cada creación en creative_events con actor 'claude'
for (const c of inserted || []) {
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: c.id,
event_type: 'creative_generated',
actor: 'claude',
to_status: 'borrador',
payload: {
angle: c.angle,
format: c.format,
generation_request_id: c.generation_request_id,
parent_id: c.parent_id,
},
visibility: 'agency',
});
}
return NextResponse.json({
ok: true,
count: inserted?.length || 0,
creatives: inserted,
});
}
export async function GET(req: NextRequest) {
const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const status = req.nextUrl.searchParams.get('status');
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
let query = adminClient
.from('creatives')
.select('*')
.eq('tenant_id', tenantId);
if (status) {
query = query.eq('status', status);
}
const { data: creatives, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(creatives || []);
}

View File

@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function POST(req: NextRequest) {
let body: any;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const auth = await authenticateIntegrationRequest(req, { requireTenant: true, parsedBody: body });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const eventType = body.event_type || 'notice';
const actor = auth.caller || body.actor || 'system';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
const { data: event, error } = await adminClient
.from('creative_events')
.insert({
tenant_id: tenantId,
creative_id: body.creative_id || null,
event_type: eventType,
actor,
from_status: body.from_status || null,
to_status: body.to_status || null,
payload: body.payload || {},
visibility: body.visibility || 'agency',
})
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({
ok: true,
event,
});
}

View File

@@ -0,0 +1,74 @@
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);
// Campos actualizables por Claude según brief: status, result, cost_estimate, error
const updates: Record<string, any> = {};
if (body.status !== undefined) updates.status = body.status;
if (body.result !== undefined) updates.result = body.result;
if (body.cost_estimate !== undefined) updates.cost_estimate = body.cost_estimate;
if (body.error !== undefined) updates.error = body.error;
if (body.status === 'completada' || body.status === 'fallida') {
updates.completed_at = new Date().toISOString();
}
const { data: updated, error } = await adminClient
.from('generation_requests')
.update(updates)
.eq('id', id)
.eq('tenant_id', tenantId)
.select('*')
.maybeSingle();
if (error || !updated) {
return NextResponse.json(
{ error: error?.message || 'Generation request not found' },
{ status: error ? 500 : 404 }
);
}
// Registrar en creative_events con actor 'claude'
await adminClient.from('creative_events').insert({
tenant_id: tenantId,
creative_id: updated.source_creative_id || null,
event_type: 'generation_updated',
actor: 'claude',
payload: {
generation_request_id: id,
status: updated.status,
cost_estimate: updated.cost_estimate,
result: updated.result,
error: updated.error,
},
visibility: 'agency',
});
return NextResponse.json({
...updated,
client_id: updated.tenant_id,
});
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
export async function GET(req: NextRequest) {
const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
if (!auth.ok) {
return auth.errorResponse!;
}
const tenantId = auth.tenantId!;
const status = req.nextUrl.searchParams.get('status');
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
const adminClient = createClient(supabaseUrl, serviceRoleKey);
let query = adminClient
.from('generation_requests')
.select('*')
.eq('tenant_id', tenantId)
.order('requested_at', { ascending: false });
if (status) {
query = query.eq('status', status);
}
const { data: requests, error } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
const mapped = (requests || []).map((r: any) => ({
...r,
client_id: r.tenant_id,
}));
return NextResponse.json(mapped);
}