feat: periodo global, métricas negocio, seguimiento notas, Drive/Social/Orgánico

Selector de fechas en header (con Personalizado) compartido en el dashboard; Métricas con Ventas/CAC/ROAS CRM, cruce canal y gráficas diarias; Seguimiento solo notas + sync Superhuman; Activos con navegación Drive; Social con métricas IG/FB; Orgánico con prioridades SEO/GEO expertas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 15:45:30 +02:00
parent eb1622c88c
commit 3425364b07
15 changed files with 1067 additions and 1011 deletions

View File

@@ -10,7 +10,9 @@ import {
export const dynamic = 'force-dynamic';
/** GET — list Drive files for active tenant folder (or ?download=fileId). */
const FOLDER_MIME = 'application/vnd.google-apps.folder';
/** GET — list Drive files for tenant folder (browse with ?folderId=). */
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
@@ -28,11 +30,7 @@ export async function GET(req: NextRequest) {
);
}
// Clients only see own tenant; agency via view cookie already in ctx
const agency = isAgencyRole(userRole(user));
if (!agency) {
// resolveActiveTenant already scopes to home tenant
}
void isAgencyRole(userRole(user));
const downloadId = req.nextUrl.searchParams.get('download')?.trim();
if (downloadId) {
@@ -53,30 +51,47 @@ export async function GET(req: NextRequest) {
files: [],
linked: false,
warning:
'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match.',
'Sin carpeta Drive enlazada. Conecta marketing@ en Agencia y ejecuta auto-match bajo «Clientes».',
});
}
const requested = req.nextUrl.searchParams.get('folderId')?.trim();
const folderId = requested || link.folderId;
try {
const files = await listFilesInFolder(link.folderId);
return NextResponse.json({
linked: true,
folderId: link.folderId,
folderName: link.folderName,
files: files.map((f) => ({
const files = await listFilesInFolder(folderId);
const mapped = files
.map((f) => ({
id: f.id,
name: f.name,
mimeType: f.mimeType,
bytes: f.size ? Number(f.size) : null,
modifiedTime: f.modifiedTime,
webViewLink: f.webViewLink,
isFolder: f.mimeType === FOLDER_MIME,
source: 'drive' as const,
})),
}))
.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
return a.name.localeCompare(b.name, 'es');
});
return NextResponse.json({
linked: true,
rootFolderId: link.folderId,
folderId,
folderName: link.folderName,
driveOpenUrl: `https://drive.google.com/drive/folders/${encodeURIComponent(folderId)}`,
embedPossible: false,
embedHint:
'Réplica vía API (misma carpeta Clientes del cliente). Embed iframe de Drive exige carpeta pública; usamos listado nativo + Abrir en Drive.',
files: mapped,
});
} catch (e) {
return NextResponse.json({
linked: true,
folderId: link.folderId,
rootFolderId: link.folderId,
folderId,
folderName: link.folderName,
files: [],
warning: e instanceof Error ? e.message : 'Drive error',

View File

@@ -7,14 +7,6 @@ import {
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
AdDailyRow,
KpiWeeklyRow,
buildChannelSeries,
resolveWeeklyRows,
sumDaily,
sumWeekly,
} from '@/lib/metrics';
import { randomUUID } from 'crypto';
export const dynamic = 'force-dynamic';
@@ -33,7 +25,7 @@ export type SeguimientoEntry = {
source?: string;
};
/** GET seguimiento timeline + metrics series for active tenant. */
/** GET — notas + histórico Superhuman (sin series ads). */
export async function GET() {
const supabase = await createClient();
const {
@@ -51,10 +43,7 @@ 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.',
hint: 'Elige un cliente en el selector para ver su seguimiento.',
});
}
@@ -64,78 +53,33 @@ export async function GET() {
}
let entries: SeguimientoEntry[] = [];
let weekly: KpiWeeklyRow[] = [];
let daily: AdDailyRow[] = [];
if (agency && ctx.viaView) {
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=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.desc&limit=5000`
),
]);
const e = await restGet<SeguimientoEntry[]>(
`/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=200`
);
entries = e.data || [];
weekly = w.data || [];
daily = d.data || [];
} else {
const [{ data: e }, { data: w }, { data: d }] = await Promise.all([
supabase
.from('seguimiento_entries')
.select('*')
.order('occurred_at', { ascending: false, nullsFirst: false })
.limit(120),
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(5000),
]);
const { data: e } = await supabase
.from('seguimiento_entries')
.select('*')
.order('occurred_at', { ascending: false, nullsFirst: false })
.limit(200);
entries = (e as SeguimientoEntry[]) || [];
weekly = (w as KpiWeeklyRow[]) || [];
daily = (d as AdDailyRow[]) || [];
}
weekly = resolveWeeklyRows(weekly, daily);
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: totalsDaily.spend,
leads: totalsDaily.leads,
},
});
}
/**
* POST — add a manual note / comentario to seguimiento.
* { title?, body, occurred_at? }
* { title?, body, occurredAt? | occurred_at? }
*/
export async function POST(req: NextRequest) {
const supabase = await createClient();
@@ -168,10 +112,10 @@ export async function POST(req: NextRequest) {
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 occurredRaw =
(typeof body.occurredAt === 'string' && body.occurredAt) ||
(typeof body.occurred_at === 'string' && body.occurred_at) ||
new Date().toISOString();
const row = {
tenant_id: ctx.tenantId,
@@ -181,7 +125,7 @@ export async function POST(req: NextRequest) {
title,
summary: text.slice(0, 280),
body_md: text,
occurred_at: occurredAt,
occurred_at: occurredRaw,
synced_at: new Date().toISOString(),
metrics: {},
};

View File

@@ -2,10 +2,49 @@ import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
export const dynamic = 'force-dynamic';
/** GET — social channel integrations for active tenant. */
const META = 'https://graph.facebook.com/v21.0';
type Integ = {
provider: string;
status: string;
external_account_id?: string | null;
label?: string | null;
last_sync_at?: string | null;
meta?: Record<string, unknown> | null;
};
async function fetchPageMetrics(token: string, pageId: string) {
const fields =
'name,fan_count,followers_count,link,picture{url},engagement';
const r = await fetch(
`${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (!r.ok) return { error: (await r.text()).slice(0, 160) };
return r.json();
}
async function fetchIgMetrics(token: string, igId: string) {
const r = await fetch(
`${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (!r.ok) return { error: (await r.text()).slice(0, 160) };
const profile = await r.json();
const ins = await fetch(
`${META}/${encodeURIComponent(igId)}/insights?metric=reach,impressions,profile_views&period=day&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
let insights: unknown = null;
if (ins.ok) insights = await ins.json();
return { ...profile, insights };
}
/** GET — social integrations + live Meta metrics when token allows. */
export async function GET() {
const supabase = await createClient();
const {
@@ -19,33 +58,59 @@ export async function GET() {
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json({
integrations: [],
metrics: {},
warning: 'Selecciona un cliente en Contexto',
});
}
const agency = isAgencyRole(userRole(user));
const providers = 'instagram,facebook,tiktok,linkedin';
let integrations: Integ[] = [];
if (agency && ctx.viaView) {
const tid = encodeURIComponent(ctx.tenantId);
const r = await restGet(
`/rest/v1/integrations?tenant_id=eq.${tid}&provider=in.(${providers})&select=provider,status,external_account_id,label,last_sync_at,meta,error_message`
);
return NextResponse.json({
integrations: r.data || [],
warning: r.error || null,
});
integrations = (r.data as Integ[]) || [];
} else {
const { data } = await supabase
.from('integrations')
.select(
'provider,status,external_account_id,label,last_sync_at,meta,error_message'
)
.in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']);
integrations = (data as Integ[]) || [];
}
const { data, error } = await supabase
.from('integrations')
.select(
'provider,status,external_account_id,label,last_sync_at,meta,error_message'
)
.in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']);
const metrics: Record<string, unknown> = {};
const token = await resolveMetaAccessToken();
if (token) {
for (const i of integrations) {
if (i.status !== 'connected' || !i.external_account_id) continue;
const id = i.external_account_id;
if (id.startsWith('http')) continue;
try {
if (i.provider === 'facebook') {
metrics.facebook = await fetchPageMetrics(token, id);
}
if (i.provider === 'instagram') {
metrics.instagram = await fetchIgMetrics(token, id);
}
} catch (e) {
metrics[i.provider] = {
error: e instanceof Error ? e.message : 'metrics error',
};
}
}
} else {
metrics.warning =
'Sin token Meta de agencia: perfiles enlazados pero sin métricas live.';
}
return NextResponse.json({
integrations: data || [],
warning: error?.message || null,
integrations,
metrics,
label: ctx.label,
});
}