feat(creatives): task 4 - claude integration endpoints (generation-requests, creatives, context, events)
This commit is contained in:
119
src/app/api/integrations/creatives/[id]/context/route.ts
Normal file
119
src/app/api/integrations/creatives/[id]/context/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
134
src/app/api/integrations/creatives/route.ts
Normal file
134
src/app/api/integrations/creatives/route.ts
Normal 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 || []);
|
||||
}
|
||||
49
src/app/api/integrations/events/route.ts
Normal file
49
src/app/api/integrations/events/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
74
src/app/api/integrations/generation-requests/[id]/route.ts
Normal file
74
src/app/api/integrations/generation-requests/[id]/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
40
src/app/api/integrations/generation-requests/route.ts
Normal file
40
src/app/api/integrations/generation-requests/route.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user