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:
4Ventures
2026-09-23 09:33:22 +02:00
parent a7704567bd
commit 0d286dc66c
12 changed files with 1295 additions and 150 deletions

View File

@@ -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 });
}