feat: upload creativos, PMax formats, portada IG/FB y seguimiento con notas
Permite subir media sin URL; previews Google PMax por ratio; calendario anual estilo Miia con filtros IG/FB y LinkedIn; seguimiento con selector de fechas, KPIs y cuadro de notas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
87
src/app/api/creatives/media/route.ts
Normal file
87
src/app/api/creatives/media/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET ?id= — redirect to signed URL for creative media */
|
||||
export async function GET(req: NextRequest) {
|
||||
const id = req.nextUrl.searchParams.get('id')?.trim() || '';
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: 'id requerido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
|
||||
}
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const headers = serviceHeaders();
|
||||
let storagePath: string | null = null;
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(id)}&tenant_id=eq.${encodeURIComponent(ctx.tenantId)}&select=storage_path,media_url`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 502 });
|
||||
}
|
||||
const rows = await r.json();
|
||||
storagePath = rows?.[0]?.storage_path || null;
|
||||
const mediaUrl = rows?.[0]?.media_url as string | undefined;
|
||||
if (!storagePath && mediaUrl && !mediaUrl.startsWith('/api/creatives/media')) {
|
||||
return NextResponse.redirect(mediaUrl);
|
||||
}
|
||||
} else {
|
||||
const { data } = await supabase
|
||||
.from('creatives')
|
||||
.select('storage_path,media_url')
|
||||
.eq('id', id)
|
||||
.maybeSingle();
|
||||
storagePath = data?.storage_path || null;
|
||||
if (
|
||||
!storagePath &&
|
||||
data?.media_url &&
|
||||
!String(data.media_url).startsWith('/api/creatives/media')
|
||||
) {
|
||||
return NextResponse.redirect(data.media_url);
|
||||
}
|
||||
}
|
||||
|
||||
if (!storagePath) {
|
||||
return NextResponse.json({ error: 'Sin media' }, { status: 404 });
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
if (!svc) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
const signed = await svc.storage
|
||||
.from(ASSETS_BUCKET)
|
||||
.createSignedUrl(storagePath, 300);
|
||||
if (signed.error || !signed.data?.signedUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: signed.error?.message || 'No signed URL' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.redirect(signed.data.signedUrl);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@/lib/agency';
|
||||
|
||||
const SELECT =
|
||||
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,mime_type,bytes,created_at,updated_at';
|
||||
'id,title,status,channel,placement,format,headline,primary_text,description,cta,media_url,brand_name,landing_url,storage_path,mime_type,bytes,created_at,updated_at';
|
||||
|
||||
const CHANNELS = new Set([
|
||||
'meta_ads',
|
||||
@@ -31,6 +31,12 @@ const FORMATS = new Set([
|
||||
'story',
|
||||
'rsa',
|
||||
'pmax',
|
||||
'pmax_landscape',
|
||||
'pmax_square',
|
||||
'pmax_portrait',
|
||||
'pmax_vertical',
|
||||
'pmax_logo',
|
||||
'youtube',
|
||||
'other',
|
||||
]);
|
||||
const ALLOWED_STATUS = new Set(['draft', 'in_review', 'approved', 'rejected']);
|
||||
|
||||
197
src/app/api/creatives/upload/route.ts
Normal file
197
src/app/api/creatives/upload/route.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import {
|
||||
ASSETS_BUCKET,
|
||||
MAX_ASSET_BYTES,
|
||||
ensureAssetsBucket,
|
||||
sanitizeFileName,
|
||||
serviceSupabase,
|
||||
} from '@/lib/assets';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
]);
|
||||
|
||||
/**
|
||||
* POST multipart: upload creative media and attach to a creative row.
|
||||
* fields: file, creativeId? (optional — creates draft if missing), title?
|
||||
*/
|
||||
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 ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const form = await req.formData();
|
||||
const file = form.get('file');
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'file requerido' }, { status: 400 });
|
||||
}
|
||||
if (file.size <= 0 || file.size > MAX_ASSET_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Archivo vacío o > ${MAX_ASSET_BYTES / (1024 * 1024)} MB` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const mime = file.type || 'application/octet-stream';
|
||||
if (!ALLOWED_MIME.has(mime) && !mime.startsWith('image/') && !mime.startsWith('video/')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Solo imagen o vídeo (jpg/png/webp/gif/mp4/webm)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const bucketOk = await ensureAssetsBucket();
|
||||
if (!bucketOk.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: bucketOk.error || 'No se pudo asegurar el bucket' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const svc = serviceSupabase();
|
||||
if (!svc) {
|
||||
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
|
||||
}
|
||||
|
||||
let creativeId =
|
||||
typeof form.get('creativeId') === 'string'
|
||||
? String(form.get('creativeId')).trim()
|
||||
: '';
|
||||
const title =
|
||||
typeof form.get('title') === 'string'
|
||||
? String(form.get('title')).trim()
|
||||
: file.name.replace(/\.[^.]+$/, '') || 'Creativo subido';
|
||||
|
||||
const headers = serviceHeaders();
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
|
||||
if (!creativeId) {
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
title: title.slice(0, 300),
|
||||
status: 'draft',
|
||||
channel: 'meta_ads',
|
||||
format: mime.startsWith('video/') ? 'video' : 'image',
|
||||
created_by: user.id,
|
||||
};
|
||||
if (agency && headers) {
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/creatives`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
creativeId = Array.isArray(data) ? data[0]?.id : data?.id;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.insert(row)
|
||||
.select('id')
|
||||
.single();
|
||||
if (error || !data) {
|
||||
return NextResponse.json(
|
||||
{ error: error?.message || 'No se pudo crear' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
creativeId = data.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!creativeId) {
|
||||
return NextResponse.json({ error: 'creativeId inválido' }, { status: 400 });
|
||||
}
|
||||
|
||||
const safeName = sanitizeFileName(file.name || 'creative.bin');
|
||||
const storagePath = `${ctx.tenantId}/creatives/${creativeId}/${randomUUID()}-${safeName}`;
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const up = await svc.storage.from(ASSETS_BUCKET).upload(storagePath, buf, {
|
||||
contentType: mime,
|
||||
upsert: false,
|
||||
});
|
||||
if (up.error) {
|
||||
return NextResponse.json({ error: up.error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const mediaUrl = `/api/creatives/media?id=${encodeURIComponent(creativeId)}`;
|
||||
const patch = {
|
||||
storage_path: storagePath,
|
||||
mime_type: mime,
|
||||
bytes: file.size,
|
||||
media_url: mediaUrl,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/creatives?id=eq.${encodeURIComponent(creativeId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(patch),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json({
|
||||
creative: Array.isArray(data) ? data[0] : data,
|
||||
media_url: mediaUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('creatives')
|
||||
.update(patch)
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
await svc.storage.from(ASSETS_BUCKET).remove([storagePath]);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ creative: data, media_url: mediaUrl });
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import {
|
||||
isAgencyRole,
|
||||
serviceHeaders,
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
AdDailyRow,
|
||||
KpiWeeklyRow,
|
||||
buildChannelSeries,
|
||||
sumDaily,
|
||||
sumWeekly,
|
||||
} from '@/lib/metrics';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -21,9 +29,10 @@ export type SeguimientoEntry = {
|
||||
metrics?: Record<string, unknown>;
|
||||
occurred_at?: string | null;
|
||||
synced_at?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
/** GET seguimiento timeline + channel evolution for active tenant. */
|
||||
/** GET seguimiento timeline + metrics series for active tenant. */
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
@@ -41,8 +50,10 @@ export async function GET() {
|
||||
label: ctx.label,
|
||||
portfolioWide: true,
|
||||
entries: [],
|
||||
weekly: [],
|
||||
daily: [],
|
||||
channelSeries: { weeks: [], channels: [], points: [] },
|
||||
hint: 'Elige un cliente en el selector para ver su seguimiento y evolución de canales.',
|
||||
hint: 'Elige un cliente en el selector para ver su seguimiento y evolución.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,13 +70,13 @@ export async function GET() {
|
||||
const tid = encodeURIComponent(tenantId);
|
||||
const [e, w, d] = await Promise.all([
|
||||
restGet<SeguimientoEntry[]>(
|
||||
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=80`
|
||||
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=120`
|
||||
),
|
||||
restGet<KpiWeeklyRow[]>(
|
||||
`/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc`
|
||||
),
|
||||
restGet<AdDailyRow[]>(
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.asc&limit=400`
|
||||
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.asc&limit=800`
|
||||
),
|
||||
]);
|
||||
entries = e.data || [];
|
||||
@@ -77,13 +88,13 @@ export async function GET() {
|
||||
.from('seguimiento_entries')
|
||||
.select('*')
|
||||
.order('occurred_at', { ascending: false, nullsFirst: false })
|
||||
.limit(80),
|
||||
.limit(120),
|
||||
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
|
||||
supabase
|
||||
.from('ad_daily')
|
||||
.select('*')
|
||||
.order('date', { ascending: true })
|
||||
.limit(400),
|
||||
.limit(800),
|
||||
]);
|
||||
entries = (e as SeguimientoEntry[]) || [];
|
||||
weekly = (w as KpiWeeklyRow[]) || [];
|
||||
@@ -91,17 +102,118 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const channelSeries = buildChannelSeries(weekly);
|
||||
const totalsWeekly = sumWeekly(weekly);
|
||||
const totalsDaily = sumDaily(daily);
|
||||
|
||||
return NextResponse.json({
|
||||
label: ctx.label,
|
||||
portfolioWide: false,
|
||||
tenantId,
|
||||
entries,
|
||||
weekly,
|
||||
daily,
|
||||
channelSeries,
|
||||
totals: {
|
||||
spend: totalsWeekly.spend || totalsDaily.spend,
|
||||
leads: totalsWeekly.leads || totalsDaily.leads,
|
||||
cpl: totalsWeekly.cpl || (totalsDaily.leads > 0 ? totalsDaily.spend / totalsDaily.leads : 0),
|
||||
cac: totalsWeekly.cac,
|
||||
revenue: totalsWeekly.revenue,
|
||||
roas: totalsWeekly.roas,
|
||||
impressions: totalsDaily.impressions,
|
||||
clicks: totalsDaily.clicks,
|
||||
},
|
||||
dailySummary: {
|
||||
days: daily.length,
|
||||
spend: daily.reduce((a, r) => a + Number(r.spend || 0), 0),
|
||||
leads: daily.reduce((a, r) => a + Number(r.platform_leads || 0), 0),
|
||||
spend: totalsDaily.spend,
|
||||
leads: totalsDaily.leads,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — add a manual note / comentario to seguimiento.
|
||||
* { title?, body, occurred_at? }
|
||||
*/
|
||||
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 ctx = await resolveActiveTenant(user);
|
||||
if (!ctx.tenantId || ctx.portfolioWide) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Selecciona un cliente en Contexto' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const text =
|
||||
typeof body.body === 'string'
|
||||
? body.body.trim()
|
||||
: typeof body.note === 'string'
|
||||
? body.note.trim()
|
||||
: '';
|
||||
if (!text) {
|
||||
return NextResponse.json({ error: 'Texto de la nota requerido' }, { status: 400 });
|
||||
}
|
||||
const title =
|
||||
typeof body.title === 'string' && body.title.trim()
|
||||
? body.title.trim().slice(0, 200)
|
||||
: `Nota · ${new Date().toLocaleDateString('es-ES')}`;
|
||||
const occurredAt =
|
||||
typeof body.occurred_at === 'string' && body.occurred_at
|
||||
? body.occurred_at
|
||||
: new Date().toISOString();
|
||||
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
source: 'manual',
|
||||
entry_type: 'note',
|
||||
external_id: `manual-${randomUUID()}`,
|
||||
title,
|
||||
summary: text.slice(0, 280),
|
||||
body_md: text,
|
||||
occurred_at: occurredAt,
|
||||
synced_at: new Date().toISOString(),
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
const agency = isAgencyRole(userRole(user));
|
||||
const headers = serviceHeaders();
|
||||
|
||||
if (agency && headers) {
|
||||
const r = await fetch(`${supabaseUrl()}/rest/v1/seguimiento_entries`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!r.ok) {
|
||||
return NextResponse.json({ error: await r.text() }, { status: 500 });
|
||||
}
|
||||
const data = await r.json();
|
||||
return NextResponse.json(
|
||||
{ entry: Array.isArray(data) ? data[0] : data },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('seguimiento_entries')
|
||||
.insert(row)
|
||||
.select('*')
|
||||
.single();
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ entry: data }, { status: 201 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user