feat: interactive seguimiento timeline and channel evolution

Sync Superhuman Docs seguimiento/KPI pages into the portal (never passwords)
and show clients a dynamic timeline plus per-channel kpi_weekly charts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-22 21:34:21 +02:00
parent 6607a02bff
commit 64156383ca
14 changed files with 1754 additions and 1 deletions

View File

@@ -0,0 +1,233 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
SUPERHUMAN_PROVIDER,
bestDocForTenant,
buildSeguimientoRowsForDoc,
listDocs,
matchDocsToTenants,
patchSuperhumanIntegration,
superhumanConfigured,
upsertSeguimientoRows,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
async function requireAgency() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
if (!superhumanConfigured()) {
return {
error: NextResponse.json(
{ error: 'Falta SUPERHUMAN_DOCS_API_KEY en el entorno' },
{ status: 503 }
),
};
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
return { headers, user };
}
/** GET — match status of Superhuman docs ↔ tenants. */
export async function GET() {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
try {
const headers = (gate as { headers: { apikey: string; Authorization: string } })
.headers;
const [docs, tenantsRes] = await Promise.all([
listDocs(),
fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
),
]);
const tenants = tenantsRes.ok
? ((await tenantsRes.json()) as { id: string; name: string; slug: string }[])
: [];
const matches = matchDocsToTenants(tenants, docs);
const integRes = await fetch(
`${supabaseUrl()}/rest/v1/integrations?provider=eq.${SUPERHUMAN_PROVIDER}&select=tenant_id,status,external_account_id,label,last_sync_at,meta,error_message`,
{ headers, cache: 'no-store' }
);
const integrations = integRes.ok ? await integRes.json() : [];
return NextResponse.json({
configured: true,
docCount: docs.length,
matches,
integrations,
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'sync status failed' },
{ status: 500 }
);
}
}
/**
* POST — sync seguimiento pages into portal.
* body: { tenantId?: string, exportBodies?: boolean, maxEntries?: number }
*/
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const headers = (gate as { headers: { apikey: string; Authorization: string } })
.headers;
const body = await req.json().catch(() => ({}));
const onlyTenant: string | undefined = body.tenantId
? String(body.tenantId)
: undefined;
const exportBodies = body.exportBodies !== false;
const maxEntries = Math.min(Number(body.maxEntries) || 24, 40);
try {
const docs = await listDocs();
const tRes = await fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
);
if (!tRes.ok) {
return NextResponse.json({ error: await tRes.text() }, { status: 500 });
}
let tenants = (await tRes.json()) as {
id: string;
name: string;
slug: string;
}[];
if (onlyTenant) {
tenants = tenants.filter((t) => t.id === onlyTenant);
}
const results: {
tenantId: string;
tenantName: string;
docId: string | null;
docName: string | null;
upserted: number;
status: string;
error?: string;
}[] = [];
for (const tenant of tenants) {
let docId: string | null = null;
let docName: string | null = null;
// Prefer already-linked integration
const integUrl = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${SUPERHUMAN_PROVIDER}&select=external_account_id,meta,label`;
const integR = await fetch(integUrl, { headers, cache: 'no-store' });
const integRows = integR.ok
? ((await integR.json()) as {
external_account_id?: string | null;
meta?: { doc_name?: string };
}[])
: [];
if (integRows[0]?.external_account_id) {
docId = integRows[0].external_account_id;
docName =
integRows[0].meta?.doc_name ||
docs.find((d) => d.id === docId)?.name ||
null;
} else {
const hit = bestDocForTenant(tenant, docs);
if (hit) {
docId = hit.doc.id;
docName = hit.doc.name;
}
}
if (!docId) {
await patchSuperhumanIntegration(tenant.id, {
status: 'disconnected',
error_message: 'Sin doc Superhuman matched por nombre',
});
results.push({
tenantId: tenant.id,
tenantName: tenant.name,
docId: null,
docName: null,
upserted: 0,
status: 'unmatched',
});
continue;
}
try {
const rows = await buildSeguimientoRowsForDoc({
tenantId: tenant.id,
docId,
maxEntries,
exportBodies,
});
const upserted = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(tenant.id, {
status: 'connected',
external_account_id: docId,
label: docName || 'Superhuman Docs',
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
doc_name: docName,
entries: upserted,
synced_at: new Date().toISOString(),
},
});
results.push({
tenantId: tenant.id,
tenantName: tenant.name,
docId,
docName,
upserted,
status: 'synced',
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'sync failed';
await patchSuperhumanIntegration(tenant.id, {
status: 'error',
external_account_id: docId,
error_message: msg.slice(0, 400),
});
results.push({
tenantId: tenant.id,
tenantName: tenant.name,
docId,
docName,
upserted: 0,
status: 'error',
error: msg,
});
}
}
return NextResponse.json({
ok: true,
results,
synced: results.filter((r) => r.status === 'synced').length,
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'sync failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,107 @@
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 {
AdDailyRow,
KpiWeeklyRow,
buildChannelSeries,
} from '@/lib/metrics';
export const dynamic = 'force-dynamic';
export type SeguimientoEntry = {
id: string;
tenant_id: string;
entry_type: string;
title: string;
summary?: string | null;
body_md?: string | null;
browser_link?: string | null;
metrics?: Record<string, unknown>;
occurred_at?: string | null;
synced_at?: string;
};
/** GET seguimiento timeline + channel evolution for active tenant. */
export async function GET() {
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);
const agency = isAgencyRole(userRole(user));
if (agency && ctx.portfolioWide) {
return NextResponse.json({
label: ctx.label,
portfolioWide: true,
entries: [],
channelSeries: { weeks: [], channels: [], points: [] },
hint: 'Elige un cliente en el selector para ver su seguimiento y evolución de canales.',
});
}
const tenantId = ctx.tenantId;
if (!tenantId) {
return NextResponse.json({ error: 'Sin tenant' }, { status: 400 });
}
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=80`
),
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`
),
]);
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(80),
supabase.from('kpi_weekly').select('*').order('week', { ascending: true }),
supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: true })
.limit(400),
]);
entries = (e as SeguimientoEntry[]) || [];
weekly = (w as KpiWeeklyRow[]) || [];
daily = (d as AdDailyRow[]) || [];
}
const channelSeries = buildChannelSeries(weekly);
return NextResponse.json({
label: ctx.label,
portfolioWide: false,
tenantId,
entries,
channelSeries,
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),
},
});
}

View File

@@ -9,7 +9,8 @@ import {
Layers,
LogOut,
UserCheck,
CalendarDays
CalendarDays,
Radio
} from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
import { logoutAction } from '@/app/auth/actions';
@@ -55,6 +56,13 @@ export default async function DashboardLayout({
<BarChart3 className="w-4 h-4 text-brand-500" />
Métricas
</Link>
<Link
href="/dashboard/seguimiento"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
>
<Radio className="w-4 h-4 text-sky-400" />
Seguimiento
</Link>
<Link
href="/dashboard/estrategia"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import SeguimientoBoard from '@/components/SeguimientoBoard';
export const dynamic = 'force-dynamic';
export default async function SeguimientoPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
return <SeguimientoBoard />;
}