feat(creatives): implement agency and client views (Tasks 6 & 7) with fatigue panel and Claude queue
This commit is contained in:
@@ -23,15 +23,7 @@ export async function POST(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const tenantId = ctx.tenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tenant context required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const role = userRole(user);
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
@@ -40,8 +32,28 @@ export async function POST(
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
let tenantId = ctx.tenantId;
|
||||
|
||||
if (!tenantId && (role === 'agency_admin' || role === 'agency_member' || role === 'admin')) {
|
||||
if (body.tenant_id) {
|
||||
tenantId = body.tenant_id;
|
||||
} else {
|
||||
const { getAdminClient } = await import('@/lib/creatives-workflow');
|
||||
const adminClient = getAdminClient();
|
||||
const { data: c } = await adminClient.from('creatives').select('tenant_id').eq('id', id).maybeSingle();
|
||||
if (c?.tenant_id) tenantId = c.tenant_id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Tenant context required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire'
|
||||
const role = userRole(user);
|
||||
const actor = { role, userId: user.id };
|
||||
|
||||
try {
|
||||
|
||||
180
src/app/api/creatives/[id]/route.ts
Normal file
180
src/app/api/creatives/[id]/route.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
|
||||
function getAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
return createAdminClient(url, key);
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const isAgency = isAgencyRole(role);
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const activeTenantId = ctx.tenantId;
|
||||
|
||||
const admin = getAdmin();
|
||||
|
||||
const { data: creative, error } = await admin
|
||||
.from('creatives')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error || !creative) {
|
||||
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Si no es agencia, validar pertenencia al tenant
|
||||
if (!isAgency && creative.tenant_id !== activeTenantId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Obtener métricas diarias
|
||||
const { data: metrics } = await admin
|
||||
.from('creative_metrics_daily')
|
||||
.select('*')
|
||||
.eq('creative_id', id)
|
||||
.order('date', { ascending: false })
|
||||
.limit(30);
|
||||
|
||||
// Obtener eventos
|
||||
const { data: events } = await admin
|
||||
.from('creative_events')
|
||||
.select('*')
|
||||
.eq('creative_id', id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
// Obtener aprobaciones
|
||||
const { data: approvals } = await admin
|
||||
.from('approvals')
|
||||
.select('*')
|
||||
.eq('creative_id', id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
const { sanitizeForRole } = await import('@/lib/creatives-visibility');
|
||||
|
||||
return NextResponse.json({
|
||||
creative: sanitizeForRole(creative, role),
|
||||
metrics: metrics || [],
|
||||
events: events || [],
|
||||
approvals: approvals || [],
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const isAgency = isAgencyRole(role);
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const activeTenantId = ctx.tenantId;
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
|
||||
const { data: existing, error: getErr } = await admin
|
||||
.from('creatives')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
|
||||
if (getErr || !existing) {
|
||||
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!isAgency && existing.tenant_id !== activeTenantId) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const prevStatus = existing.status;
|
||||
const updates: Record<string, any> = {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (body.asset_url !== undefined) updates.asset_url = body.asset_url;
|
||||
if (body.thumbnail_url !== undefined) updates.thumbnail_url = body.thumbnail_url;
|
||||
if (body.asset_final !== undefined) updates.asset_final = Boolean(body.asset_final);
|
||||
if (body.headline !== undefined) updates.headline = body.headline;
|
||||
if (body.primary_text !== undefined) updates.primary_text = body.primary_text;
|
||||
if (body.cta !== undefined) updates.cta = body.cta;
|
||||
if (body.angle !== undefined) updates.angle = body.angle;
|
||||
if (body.hook !== undefined) updates.hook = body.hook;
|
||||
if (body.campaign_name !== undefined) updates.campaign_name = body.campaign_name;
|
||||
if (body.adset_id !== undefined) updates.adset_id = body.adset_id;
|
||||
if (body.format !== undefined) updates.format = body.format;
|
||||
if (body.visibility !== undefined) updates.visibility = body.visibility;
|
||||
if (body.status !== undefined) updates.status = body.status;
|
||||
|
||||
const { data: updated, error: updateErr } = await admin
|
||||
.from('creatives')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (updateErr) {
|
||||
return NextResponse.json({ error: updateErr.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Si cambia el status o se sube imagen final, registrar en creative_events
|
||||
if (updates.status && updates.status !== prevStatus) {
|
||||
await admin.from('creative_events').insert({
|
||||
tenant_id: existing.tenant_id,
|
||||
creative_id: id,
|
||||
event_type: 'status_change',
|
||||
actor: `user:${user.id}`,
|
||||
from_status: prevStatus,
|
||||
to_status: updates.status,
|
||||
payload: { changed_by: role, reason: body.status_reason || null },
|
||||
visibility: updates.status === 'publicada' || updates.status === 'aprobada' ? 'client' : 'agency',
|
||||
});
|
||||
} else if (updates.asset_final && !existing.asset_final) {
|
||||
await admin.from('creative_events').insert({
|
||||
tenant_id: existing.tenant_id,
|
||||
creative_id: id,
|
||||
event_type: 'asset_final_uploaded',
|
||||
actor: `user:${user.id}`,
|
||||
from_status: prevStatus,
|
||||
to_status: prevStatus,
|
||||
payload: { asset_url: updates.asset_url },
|
||||
visibility: 'agency',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, creative: updated });
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { sanitizeForRole } from '@/lib/creatives-visibility';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
|
||||
const SELECT =
|
||||
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,created_at,updated_at';
|
||||
'id,tenant_id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,meta,campaign_name,adset_id,meta_ad_id,angle,hook,asset_url,thumbnail_url,visibility,parent_id,generation_request_id,asset_final,created_by,created_at,updated_at';
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'meta_ads',
|
||||
@@ -90,7 +92,7 @@ async function fetchCurrentStatus(
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
@@ -99,76 +101,112 @@ export async function GET() {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
const tenantId = ctx.tenantId;
|
||||
const viaView = ctx.viaView;
|
||||
const role = userRole(user);
|
||||
const isAgency = isAgencyRole(role);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const queryTenantId = searchParams.get('tenant_id');
|
||||
|
||||
if (viaView && tenantId) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ creatives: [], warning: 'Service role required for superadmin view' },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=${SELECT}&order=updated_at.desc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
// Fallback if preview columns not migrated yet
|
||||
const fallback = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?tenant_id=eq.${encodeURIComponent(tenantId)}&select=id,title,status,mime_type,bytes,created_at,updated_at&order=updated_at.desc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (fallback.ok) {
|
||||
return NextResponse.json({
|
||||
creatives: await fallback.json(),
|
||||
viewTenantId: tenantId,
|
||||
warning:
|
||||
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
creatives: [],
|
||||
warning: await r.text(),
|
||||
viewTenantId: tenantId,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
creatives: await r.json(),
|
||||
viewTenantId: tenantId,
|
||||
isAgency: isAgencyRole(userRole(user)),
|
||||
});
|
||||
let tenantId: string | null = null;
|
||||
if (isAgency && queryTenantId) {
|
||||
tenantId = queryTenantId;
|
||||
} else {
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
tenantId = ctx.tenantId;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ creatives: [], isAgency });
|
||||
}
|
||||
|
||||
const adminUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const adminKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const admin = createAdminClient(adminUrl, adminKey);
|
||||
|
||||
let query = admin
|
||||
.from('creatives')
|
||||
.select(SELECT)
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
const { data: basic, error: e2 } = await supabase
|
||||
.from('creatives')
|
||||
.select('id,title,status,mime_type,bytes,created_at,updated_at')
|
||||
.order('updated_at', { ascending: false });
|
||||
if (e2) {
|
||||
return NextResponse.json(
|
||||
{ creatives: [], warning: error.message },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
creatives: basic ?? [],
|
||||
isAgency: isAgencyRole(userRole(user)),
|
||||
warning:
|
||||
'Aplica sql/creatives_preview.sql para canales, copy y media preview',
|
||||
});
|
||||
// Si es cliente, aplicar visibilidad estricta
|
||||
if (!isAgency) {
|
||||
query = query
|
||||
.eq('visibility', 'client')
|
||||
.in('status', ['pendiente_aprobacion', 'aprobada', 'publicada', 'fatigada', 'retirada', 'rechazada']);
|
||||
}
|
||||
|
||||
const { data: rows, error } = await query;
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const creatives = rows || [];
|
||||
|
||||
// Obtener métricas agregadas de los últimos 30 días para estas creatividades
|
||||
const creativeIds = creatives.map((c: any) => c.id);
|
||||
const metricsMap: Record<string, any> = {};
|
||||
|
||||
if (creativeIds.length > 0) {
|
||||
const { data: metrics } = await admin
|
||||
.from('creative_metrics_daily')
|
||||
.select('creative_id, impressions, clicks, spend, results, frequency, ctr, cpa')
|
||||
.in('creative_id', creativeIds);
|
||||
|
||||
if (metrics && metrics.length > 0) {
|
||||
for (const m of metrics) {
|
||||
const cid = m.creative_id;
|
||||
metricsMap[cid] = metricsMap[cid] || {
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
spend: 0,
|
||||
results: 0,
|
||||
count: 0,
|
||||
freq_sum: 0,
|
||||
ctr_sum: 0,
|
||||
cpa_sum: 0,
|
||||
cpa_count: 0,
|
||||
};
|
||||
metricsMap[cid].impressions += Number(m.impressions || 0);
|
||||
metricsMap[cid].clicks += Number(m.clicks || 0);
|
||||
metricsMap[cid].spend += Number(m.spend || 0);
|
||||
metricsMap[cid].results += Number(m.results || 0);
|
||||
metricsMap[cid].count += 1;
|
||||
metricsMap[cid].freq_sum += Number(m.frequency || 0);
|
||||
metricsMap[cid].ctr_sum += Number(m.ctr || 0);
|
||||
if (m.cpa != null) {
|
||||
metricsMap[cid].cpa_sum += Number(m.cpa);
|
||||
metricsMap[cid].cpa_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adjuntar métricas resumidas
|
||||
const enriched = creatives.map((c: any) => {
|
||||
const agg = metricsMap[c.id];
|
||||
const metrics = agg
|
||||
? {
|
||||
impressions: agg.impressions,
|
||||
clicks: agg.clicks,
|
||||
spend: Number(agg.spend.toFixed(2)),
|
||||
results: agg.results,
|
||||
frequency: agg.count > 0 ? Number((agg.freq_sum / agg.count).toFixed(2)) : 0,
|
||||
ctr: agg.impressions > 0 ? Number(((agg.clicks / agg.impressions) * 100).toFixed(2)) : 0,
|
||||
cpa: agg.cpa_count > 0 ? Number((agg.cpa_sum / agg.cpa_count).toFixed(2)) : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
...c,
|
||||
metrics,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
creatives: data ?? [],
|
||||
isAgency: isAgencyRole(userRole(user)),
|
||||
creatives: sanitizeForRole(enriched, role),
|
||||
isAgency,
|
||||
tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -150,8 +150,13 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: up.error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const mediaUrl = `/api/creatives/media?id=${encodeURIComponent(creativeId)}`;
|
||||
const patch = {
|
||||
const mediaUrl = `/api/creatives/media?path=${encodeURIComponent(storagePath)}`;
|
||||
|
||||
const isFinal =
|
||||
form.get('is_final') === 'true' ||
|
||||
form.get('asset_final') === 'true';
|
||||
|
||||
const patch: Record<string, any> = {
|
||||
storage_path: storagePath,
|
||||
mime_type: mime,
|
||||
bytes: file.size,
|
||||
@@ -159,6 +164,12 @@ export async function POST(req: NextRequest) {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (isFinal) {
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.4ventures.es';
|
||||
patch.asset_url = `${appUrl.replace(/\/$/, '')}${mediaUrl}`;
|
||||
patch.asset_final = true;
|
||||
}
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
|
||||
|
||||
125
src/app/api/fatigue/rules/route.ts
Normal file
125
src/app/api/fatigue/rules/route.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
|
||||
const DEFAULT_RULES = {
|
||||
lookback_days: 7,
|
||||
min_impressions: 1000,
|
||||
max_frequency: 2.8,
|
||||
ctr_drop_pct: 35,
|
||||
cpa_rise_pct: 40,
|
||||
signals_required: 2,
|
||||
auto_request_relief: true,
|
||||
};
|
||||
|
||||
function getAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
return createAdminClient(url, key);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const isAgency = isAgencyRole(role);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const queryTenantId = searchParams.get('tenant_id');
|
||||
|
||||
let tenantId: string | null = null;
|
||||
if (isAgency && queryTenantId) {
|
||||
tenantId = queryTenantId;
|
||||
} else {
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
tenantId = ctx.tenantId;
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'Tenant context required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
const { data: rules, error } = await admin
|
||||
.from('fatigue_rules')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!rules) {
|
||||
return NextResponse.json({
|
||||
rules: {
|
||||
tenant_id: tenantId,
|
||||
...DEFAULT_RULES,
|
||||
},
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ rules, isDefault: false });
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
if (!isAgencyRole(role)) {
|
||||
return NextResponse.json({ error: 'Solo la agencia puede modificar reglas de fatiga' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tenantId = body.tenant_id;
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = {
|
||||
tenant_id: tenantId,
|
||||
lookback_days: Math.max(1, Number(body.lookback_days || DEFAULT_RULES.lookback_days)),
|
||||
min_impressions: Math.max(0, Number(body.min_impressions || DEFAULT_RULES.min_impressions)),
|
||||
max_frequency: Math.max(1, Number(body.max_frequency || DEFAULT_RULES.max_frequency)),
|
||||
ctr_drop_pct: Math.max(1, Number(body.ctr_drop_pct || DEFAULT_RULES.ctr_drop_pct)),
|
||||
cpa_rise_pct: Math.max(1, Number(body.cpa_rise_pct || DEFAULT_RULES.cpa_rise_pct)),
|
||||
signals_required: Math.min(3, Math.max(1, Number(body.signals_required || DEFAULT_RULES.signals_required))),
|
||||
auto_request_relief: body.auto_request_relief !== undefined ? Boolean(body.auto_request_relief) : DEFAULT_RULES.auto_request_relief,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const admin = getAdmin();
|
||||
const { data: updated, error } = await admin
|
||||
.from('fatigue_rules')
|
||||
.upsert(payload, { onConflict: 'tenant_id' })
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, rules: updated });
|
||||
}
|
||||
137
src/app/api/generation-requests/route.ts
Normal file
137
src/app/api/generation-requests/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { createClient as createAdminClient } from '@supabase/supabase-js';
|
||||
|
||||
function getAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
return createAdminClient(url, key);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
const isAgency = isAgencyRole(role);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const queryTenantId = searchParams.get('tenant_id');
|
||||
|
||||
let tenantId: string | null = null;
|
||||
if (isAgency && queryTenantId) {
|
||||
tenantId = queryTenantId;
|
||||
} else {
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
tenantId = ctx.tenantId;
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'Tenant context required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
const { data: requests, error } = await admin
|
||||
.from('generation_requests')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('requested_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ requests });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const role = userRole(user);
|
||||
if (!isAgencyRole(role)) {
|
||||
return NextResponse.json({ error: 'Solo la agencia puede solicitar relevos a Claude' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: any;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const tenantId = body.tenant_id;
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ error: 'tenant_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const admin = getAdmin();
|
||||
|
||||
// Get tenant slug
|
||||
const { data: tenant } = await admin
|
||||
.from('tenants')
|
||||
.select('slug, name')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const brief = {
|
||||
tenant_id: tenantId,
|
||||
client_id: tenantId,
|
||||
client_slug: tenant?.slug || 'cliente',
|
||||
reason: body.reason || 'manual',
|
||||
source_creative_id: body.source_creative_id || null,
|
||||
format: body.format || 'estatico',
|
||||
variants_requested: Number(body.variants_requested || 3),
|
||||
campaign_name: body.campaign_name || null,
|
||||
adset_id: body.adset_id || null,
|
||||
notes: body.notes || null,
|
||||
};
|
||||
|
||||
const { data: created, error } = await admin
|
||||
.from('generation_requests')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
reason: brief.reason,
|
||||
source_creative_id: brief.source_creative_id,
|
||||
brief,
|
||||
status: 'pendiente',
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Registrar en creative_events
|
||||
await admin.from('creative_events').insert({
|
||||
tenant_id: tenantId,
|
||||
creative_id: brief.source_creative_id,
|
||||
event_type: 'generation_requested',
|
||||
actor: `user:${user.id}`,
|
||||
from_status: null,
|
||||
to_status: null,
|
||||
payload: {
|
||||
generation_request_id: created.id,
|
||||
reason: brief.reason,
|
||||
variants_requested: brief.variants_requested,
|
||||
},
|
||||
visibility: 'agency',
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, request: created });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import AgencyCreativesView from '@/components/AgencyCreativesView';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function AgencyClienteCreatividadesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect('/login');
|
||||
if (!isAgencyRole(userRole(user))) redirect('/dashboard/kpis');
|
||||
|
||||
let name = 'Cliente';
|
||||
const headers = serviceHeaders();
|
||||
if (headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?id=eq.${encodeURIComponent(id)}&select=name,slug`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (r.ok) {
|
||||
const rows = await r.json();
|
||||
if (rows[0]?.name) name = rows[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
return <AgencyCreativesView tenantId={id} clientName={name} />;
|
||||
}
|
||||
@@ -1,11 +1,46 @@
|
||||
/**
|
||||
* Pipeline de creatividades — producción editorial (agencia programa IG/FB).
|
||||
* El cliente solo ve y aprueba/rechaza en revisión interna.
|
||||
* Pipeline de creatividades — selector entre Aprobación Meta Ads y Pipeline RRSS.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import CreativePipelineBoard from '@/components/CreativePipelineBoard';
|
||||
import ClientCreativesView from '@/components/ClientCreativesView';
|
||||
import { Sparkles, CalendarDays } from 'lucide-react';
|
||||
|
||||
export default function CreativesPage() {
|
||||
return <CreativePipelineBoard />;
|
||||
const [mode, setMode] = useState<'ads' | 'social'>('ads');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-4 pt-2 border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('ads')}
|
||||
className={`flex items-center gap-1.5 pb-2.5 px-3 text-xs font-semibold border-b-2 transition-colors ${
|
||||
mode === 'ads'
|
||||
? 'border-brand-500 text-brand-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Aprobación & Anuncios Meta Ads
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('social')}
|
||||
className={`flex items-center gap-1.5 pb-2.5 px-3 text-xs font-semibold border-b-2 transition-colors ${
|
||||
mode === 'social'
|
||||
? 'border-brand-500 text-brand-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5" />
|
||||
Pipeline Editorial RRSS
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'ads' ? <ClientCreativesView /> : <CreativePipelineBoard />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/app/dashboard/creatividades/page.tsx
Normal file
20
src/app/dashboard/creatividades/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import ClientCreativesView from '@/components/ClientCreativesView';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function CreatividadesPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
|
||||
return <ClientCreativesView tenantName={ctx.label || 'Cliente'} />;
|
||||
}
|
||||
Reference in New Issue
Block a user