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:
@@ -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',
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user