diff --git a/src/app/api/integrations/creatives/[id]/context/route.ts b/src/app/api/integrations/creatives/[id]/context/route.ts new file mode 100644 index 0000000..dc42f6c --- /dev/null +++ b/src/app/api/integrations/creatives/[id]/context/route.ts @@ -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(); + (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, + }); +} diff --git a/src/app/api/integrations/creatives/route.ts b/src/app/api/integrations/creatives/route.ts new file mode 100644 index 0000000..58c4bcd --- /dev/null +++ b/src/app/api/integrations/creatives/route.ts @@ -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 || []); +} diff --git a/src/app/api/integrations/events/route.ts b/src/app/api/integrations/events/route.ts new file mode 100644 index 0000000..d567d3b --- /dev/null +++ b/src/app/api/integrations/events/route.ts @@ -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, + }); +} diff --git a/src/app/api/integrations/generation-requests/[id]/route.ts b/src/app/api/integrations/generation-requests/[id]/route.ts new file mode 100644 index 0000000..0a5bc53 --- /dev/null +++ b/src/app/api/integrations/generation-requests/[id]/route.ts @@ -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 = {}; + + 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, + }); +} diff --git a/src/app/api/integrations/generation-requests/route.ts b/src/app/api/integrations/generation-requests/route.ts new file mode 100644 index 0000000..580d230 --- /dev/null +++ b/src/app/api/integrations/generation-requests/route.ts @@ -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); +}