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:
@@ -17,6 +17,7 @@ export const INTEGRATION_PROVIDERS = [
|
||||
{ id: 'facebook', label: 'Facebook Page', group: 'social' },
|
||||
{ id: 'tiktok', label: 'TikTok', group: 'social' },
|
||||
{ id: 'linkedin', label: 'LinkedIn', group: 'social' },
|
||||
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops' },
|
||||
] as const;
|
||||
|
||||
export type IntegrationProviderId =
|
||||
|
||||
@@ -186,4 +186,66 @@ export function formatNum(v: number): string {
|
||||
return v.toLocaleString('es-ES', { maximumFractionDigits: 1 });
|
||||
}
|
||||
|
||||
export type ChannelWeekPoint = {
|
||||
week: string; // YYYY-MM-DD
|
||||
channel: string;
|
||||
spend: number;
|
||||
leads: number;
|
||||
deals: number;
|
||||
revenue: number;
|
||||
cpl: number;
|
||||
cac: number;
|
||||
roas: number;
|
||||
};
|
||||
|
||||
/** Collapse kpi_weekly rows into per-channel weekly series for charts. */
|
||||
export function buildChannelSeries(rows: KpiWeeklyRow[]): {
|
||||
weeks: string[];
|
||||
channels: string[];
|
||||
points: ChannelWeekPoint[];
|
||||
} {
|
||||
const map = new Map<string, ChannelWeekPoint>();
|
||||
for (const r of rows) {
|
||||
const d = parseWeek(r.week);
|
||||
if (!d) continue;
|
||||
const week = isoDate(weekStart(d));
|
||||
const channel = (r.channel || 'otros').toLowerCase();
|
||||
const key = `${week}|${channel}`;
|
||||
const spend = n(r.total_spend);
|
||||
const leads = n(r.total_leads);
|
||||
const deals = n(r.cohort_deals_won);
|
||||
const revenue = n(r.cohort_revenue_won);
|
||||
const prev = map.get(key);
|
||||
if (prev) {
|
||||
prev.spend += spend;
|
||||
prev.leads += leads;
|
||||
prev.deals += deals;
|
||||
prev.revenue += revenue;
|
||||
prev.cpl = prev.leads > 0 ? prev.spend / prev.leads : 0;
|
||||
prev.cac = prev.deals > 0 ? prev.spend / prev.deals : 0;
|
||||
prev.roas = prev.spend > 0 ? prev.revenue / prev.spend : 0;
|
||||
} else {
|
||||
map.set(key, {
|
||||
week,
|
||||
channel,
|
||||
spend,
|
||||
leads,
|
||||
deals,
|
||||
revenue,
|
||||
cpl: leads > 0 ? spend / leads : 0,
|
||||
cac: deals > 0 ? spend / deals : 0,
|
||||
roas: spend > 0 ? revenue / spend : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
const points = [...map.values()].sort((a, b) =>
|
||||
a.week === b.week
|
||||
? a.channel.localeCompare(b.channel)
|
||||
: a.week.localeCompare(b.week)
|
||||
);
|
||||
const weeks = [...new Set(points.map((p) => p.week))].sort();
|
||||
const channels = [...new Set(points.map((p) => p.channel))].sort();
|
||||
return { weeks, channels, points };
|
||||
}
|
||||
|
||||
export { isoDate, weekStart };
|
||||
|
||||
451
src/lib/superhuman-docs.ts
Normal file
451
src/lib/superhuman-docs.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
/** Superhuman Docs (= Coda) client: match docs↔tenants, sync seguimiento pages. */
|
||||
|
||||
import { gunzipSync } from 'zlib';
|
||||
import { matchAccountsToTenants, normalizeName, scoreNameToTenant } from '@/lib/match';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
export const SUPERHUMAN_PROVIDER = 'superhuman_docs' as const;
|
||||
export const SUPERHUMAN_API_BASE = 'https://docs.superhuman.com/apis/v1';
|
||||
|
||||
const PASSWORD_RE = /contrase|password|secreto|credential/i;
|
||||
const SEGUIMIENTO_RE = /seguim/i;
|
||||
const KPI_RE = /^kpi|kpis\b/i;
|
||||
|
||||
export type ShDoc = { id: string; name: string; browserLink?: string };
|
||||
export type ShPage = {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
browserLink?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
parent?: { id: string; name?: string } | null;
|
||||
children?: { id: string; name?: string }[];
|
||||
};
|
||||
|
||||
export type ParsedMetrics = {
|
||||
ventas?: number;
|
||||
cpl?: number;
|
||||
cac?: number;
|
||||
leads?: number;
|
||||
spend?: number;
|
||||
periodo?: string;
|
||||
};
|
||||
|
||||
export type SeguimientoSyncRow = {
|
||||
tenant_id: string;
|
||||
source: 'superhuman';
|
||||
entry_type: 'meeting' | 'topic' | 'kpis' | 'note';
|
||||
external_id: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
body_md: string | null;
|
||||
browser_link: string | null;
|
||||
metrics: ParsedMetrics;
|
||||
occurred_at: string | null;
|
||||
synced_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function apiKey(): string | null {
|
||||
return process.env.SUPERHUMAN_DOCS_API_KEY?.trim() || null;
|
||||
}
|
||||
|
||||
export function superhumanConfigured(): boolean {
|
||||
return Boolean(apiKey());
|
||||
}
|
||||
|
||||
async function shFetch<T = unknown>(
|
||||
path: string,
|
||||
init?: RequestInit & { query?: Record<string, string> }
|
||||
): Promise<T> {
|
||||
const key = apiKey();
|
||||
if (!key) throw new Error('Falta SUPERHUMAN_DOCS_API_KEY');
|
||||
const q = init?.query
|
||||
? '?' + new URLSearchParams(init.query).toString()
|
||||
: '';
|
||||
const r = await fetch(`${SUPERHUMAN_API_BASE}${path}${q}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
Accept: 'application/json',
|
||||
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`Superhuman ${r.status}: ${(await r.text()).slice(0, 240)}`);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
export async function listDocs(): Promise<ShDoc[]> {
|
||||
const out: ShDoc[] = [];
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const query: Record<string, string> = { limit: '50' };
|
||||
if (pageToken) query.pageToken = pageToken;
|
||||
const data = await shFetch<{
|
||||
items?: ShDoc[];
|
||||
nextPageToken?: string;
|
||||
}>('/docs', { query });
|
||||
out.push(...(data.items || []));
|
||||
pageToken = data.nextPageToken;
|
||||
} while (pageToken);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function listPages(docId: string): Promise<ShPage[]> {
|
||||
const out: ShPage[] = [];
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
const query: Record<string, string> = { limit: '100' };
|
||||
if (pageToken) query.pageToken = pageToken;
|
||||
const data = await shFetch<{
|
||||
items?: ShPage[];
|
||||
nextPageToken?: string;
|
||||
}>(`/docs/${encodeURIComponent(docId)}/pages`, { query });
|
||||
out.push(...(data.items || []));
|
||||
pageToken = data.nextPageToken;
|
||||
} while (pageToken);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strip "01." / numbering prefix used in client doc titles. */
|
||||
export function cleanClientDocName(name: string): string {
|
||||
return name.replace(/^\s*\d+\.\s*/, '').trim();
|
||||
}
|
||||
|
||||
export function isPasswordSensitiveName(...names: (string | undefined | null)[]): boolean {
|
||||
return names.some((n) => n && PASSWORD_RE.test(n));
|
||||
}
|
||||
|
||||
export function parseMetricsFromMarkdown(md: string): ParsedMetrics {
|
||||
const metrics: ParsedMetrics = {};
|
||||
const periodo = md.match(/\*\*Periodo\*\*:\s*([^\n*]+)/i);
|
||||
if (periodo) metrics.periodo = periodo[1].trim();
|
||||
|
||||
const ventas = md.match(/\*\*Ventas:\s*([\d.,]+)\*\*/i) || md.match(/Ventas:\s*([\d.,]+)/i);
|
||||
if (ventas) metrics.ventas = Number(String(ventas[1]).replace(',', '.'));
|
||||
|
||||
const cpls = [...md.matchAll(/CPL:\s*([\d.,]+)\s*€?/gi)].map((m) =>
|
||||
Number(String(m[1]).replace(',', '.'))
|
||||
);
|
||||
if (cpls.length) {
|
||||
metrics.cpl = cpls.reduce((a, b) => a + b, 0) / cpls.length;
|
||||
}
|
||||
|
||||
const cacs = [...md.matchAll(/CAC:\s*([\d.,]+)\s*€?/gi)].map((m) =>
|
||||
Number(String(m[1]).replace(',', '.'))
|
||||
);
|
||||
if (cacs.length) {
|
||||
metrics.cac = cacs.reduce((a, b) => a + b, 0) / cacs.length;
|
||||
}
|
||||
|
||||
const leads = md.match(/Leads?:\s*([\d.,]+)/i);
|
||||
if (leads) metrics.leads = Number(String(leads[1]).replace(',', '.'));
|
||||
|
||||
const spend = md.match(/Inversi[oó]n:\s*([\d.,]+)\s*€?/i);
|
||||
if (spend) metrics.spend = Number(String(spend[1]).replace(',', '.'));
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async function exportPageMarkdown(
|
||||
docId: string,
|
||||
pageId: string,
|
||||
timeoutMs = 25000
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const start = await shFetch<{ id: string; href?: string; status?: string }>(
|
||||
`/docs/${encodeURIComponent(docId)}/pages/${encodeURIComponent(pageId)}/export`,
|
||||
{ method: 'POST', body: JSON.stringify({ outputFormat: 'markdown' }) }
|
||||
);
|
||||
const statusUrl =
|
||||
start.href ||
|
||||
`${SUPERHUMAN_API_BASE}/docs/${encodeURIComponent(docId)}/pages/${encodeURIComponent(pageId)}/export/${start.id}`;
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
const key = apiKey()!;
|
||||
const r = await fetch(statusUrl, {
|
||||
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
const st = (await r.json()) as {
|
||||
status?: string;
|
||||
downloadLink?: string;
|
||||
};
|
||||
if (st.status === 'complete' && st.downloadLink) {
|
||||
const dl = await fetch(st.downloadLink, {
|
||||
headers: { 'Accept-Encoding': 'identity' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const buf = Buffer.from(await dl.arrayBuffer());
|
||||
const text =
|
||||
buf[0] === 0x1f && buf[1] === 0x8b
|
||||
? gunzipSync(buf).toString('utf8')
|
||||
: buf.toString('utf8');
|
||||
return text;
|
||||
}
|
||||
if (st.status === 'failed' || st.status === 'error') return null;
|
||||
await new Promise((res) => setTimeout(res, 900));
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function excerpt(md: string, max = 1400): string {
|
||||
const clean = md.replace(/\r\n/g, '\n').trim();
|
||||
if (clean.length <= max) return clean;
|
||||
return clean.slice(0, max).trimEnd() + '…';
|
||||
}
|
||||
|
||||
function classifyEntry(title: string, underSeguimiento: boolean): SeguimientoSyncRow['entry_type'] {
|
||||
if (KPI_RE.test(title)) return 'kpis';
|
||||
if (/reuni[oó]n|kick\s*off|seguimiento semanal/i.test(title)) return 'meeting';
|
||||
if (underSeguimiento) return 'topic';
|
||||
return 'note';
|
||||
}
|
||||
|
||||
function pageAncestors(
|
||||
page: ShPage,
|
||||
byId: Map<string, ShPage>
|
||||
): ShPage[] {
|
||||
const chain: ShPage[] = [];
|
||||
let cur: ShPage | undefined = page;
|
||||
const seen = new Set<string>();
|
||||
while (cur?.parent?.id && !seen.has(cur.parent.id)) {
|
||||
seen.add(cur.parent.id);
|
||||
const p = byId.get(cur.parent.id);
|
||||
if (!p) break;
|
||||
chain.push(p);
|
||||
cur = p;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** Collect seguimiento / KPI pages safe for client portal. */
|
||||
export function selectClientFacingPages(pages: ShPage[]): {
|
||||
roots: ShPage[];
|
||||
entries: { page: ShPage; underSeguimiento: boolean }[];
|
||||
} {
|
||||
const byId = new Map(pages.map((p) => [p.id, p]));
|
||||
const roots = pages.filter(
|
||||
(p) => SEGUIMIENTO_RE.test(p.name) || KPI_RE.test(p.name)
|
||||
);
|
||||
const rootIds = new Set(roots.map((r) => r.id));
|
||||
const entries: { page: ShPage; underSeguimiento: boolean }[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
if (isPasswordSensitiveName(page.name)) continue;
|
||||
const ancestors = pageAncestors(page, byId);
|
||||
if (ancestors.some((a) => isPasswordSensitiveName(a.name))) continue;
|
||||
|
||||
const underSeg = ancestors.some((a) => rootIds.has(a.id) && SEGUIMIENTO_RE.test(a.name));
|
||||
const isRoot = rootIds.has(page.id);
|
||||
const underKpi = ancestors.some((a) => rootIds.has(a.id) && KPI_RE.test(a.name));
|
||||
|
||||
if (isRoot || underSeg || (underKpi && page.parent)) {
|
||||
// Skip deep password children already filtered; skip empty password roots
|
||||
if (isPasswordSensitiveName(page.name)) continue;
|
||||
entries.push({
|
||||
page,
|
||||
underSeguimiento: underSeg || SEGUIMIENTO_RE.test(page.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer freshest first; cap later at sync
|
||||
entries.sort((a, b) => {
|
||||
const ta = Date.parse(a.page.updatedAt || a.page.createdAt || '') || 0;
|
||||
const tb = Date.parse(b.page.updatedAt || b.page.createdAt || '') || 0;
|
||||
return tb - ta;
|
||||
});
|
||||
|
||||
return { roots, entries };
|
||||
}
|
||||
|
||||
export type DocMatchResult = {
|
||||
docId: string;
|
||||
docName: string;
|
||||
tenantId: string | null;
|
||||
tenantName: string | null;
|
||||
score: number;
|
||||
status: 'matched' | 'ambiguous' | 'unmatched';
|
||||
};
|
||||
|
||||
export function matchDocsToTenants(
|
||||
tenants: { id: string; name: string; slug: string }[],
|
||||
docs: ShDoc[]
|
||||
): DocMatchResult[] {
|
||||
const accounts = docs.map((d) => ({
|
||||
id: d.id,
|
||||
name: cleanClientDocName(d.name),
|
||||
}));
|
||||
const matched = matchAccountsToTenants(tenants, accounts, {
|
||||
threshold: 50,
|
||||
ambiguousGap: 8,
|
||||
});
|
||||
return matched.map((m) => ({
|
||||
docId: m.accountId || '',
|
||||
docName:
|
||||
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
|
||||
tenantId: m.status === 'matched' ? m.tenantId : null,
|
||||
tenantName: m.status === 'matched' ? m.tenantName : null,
|
||||
score: m.score,
|
||||
status: m.status,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Best single-doc match for one tenant (manual zoom). */
|
||||
export function bestDocForTenant(
|
||||
tenant: { name: string; slug: string },
|
||||
docs: ShDoc[]
|
||||
): { doc: ShDoc; score: number } | null {
|
||||
let best: { doc: ShDoc; score: number } | null = null;
|
||||
for (const d of docs) {
|
||||
const score = scoreNameToTenant(
|
||||
cleanClientDocName(d.name),
|
||||
tenant.name,
|
||||
tenant.slug
|
||||
);
|
||||
if (!best || score > best.score) best = { doc: d, score };
|
||||
}
|
||||
if (!best || best.score < 50) return null;
|
||||
return best;
|
||||
}
|
||||
|
||||
export async function buildSeguimientoRowsForDoc(opts: {
|
||||
tenantId: string;
|
||||
docId: string;
|
||||
maxEntries?: number;
|
||||
exportBodies?: boolean;
|
||||
}): Promise<SeguimientoSyncRow[]> {
|
||||
const maxEntries = opts.maxEntries ?? 30;
|
||||
const exportBodies = opts.exportBodies !== false;
|
||||
const pages = await listPages(opts.docId);
|
||||
const { entries } = selectClientFacingPages(pages);
|
||||
const now = new Date().toISOString();
|
||||
const rows: SeguimientoSyncRow[] = [];
|
||||
|
||||
for (const { page, underSeguimiento } of entries.slice(0, maxEntries)) {
|
||||
let body: string | null = null;
|
||||
let metrics: ParsedMetrics = {};
|
||||
if (exportBodies && !SEGUIMIENTO_RE.test(page.name)) {
|
||||
// Export leaf/topic pages (skip huge root containers)
|
||||
body = await exportPageMarkdown(opts.docId, page.id);
|
||||
if (body) {
|
||||
metrics = parseMetricsFromMarkdown(body);
|
||||
body = excerpt(body);
|
||||
}
|
||||
} else if (exportBodies && KPI_RE.test(page.name)) {
|
||||
body = await exportPageMarkdown(opts.docId, page.id);
|
||||
if (body) {
|
||||
metrics = parseMetricsFromMarkdown(body);
|
||||
body = excerpt(body, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
const summaryBits = [
|
||||
page.subtitle?.trim(),
|
||||
metrics.periodo ? `Periodo: ${metrics.periodo}` : null,
|
||||
metrics.ventas != null ? `Ventas: ${metrics.ventas}` : null,
|
||||
metrics.cpl != null ? `CPL≈${metrics.cpl.toFixed(1)}€` : null,
|
||||
metrics.cac != null ? `CAC≈${metrics.cac.toFixed(0)}€` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
rows.push({
|
||||
tenant_id: opts.tenantId,
|
||||
source: 'superhuman',
|
||||
entry_type: classifyEntry(page.name, underSeguimiento),
|
||||
external_id: page.id,
|
||||
title: page.name,
|
||||
summary: summaryBits.length ? summaryBits.join(' · ') : null,
|
||||
body_md: body,
|
||||
browser_link: page.browserLink || null,
|
||||
metrics,
|
||||
occurred_at: page.updatedAt || page.createdAt || null,
|
||||
synced_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function upsertSeguimientoRows(
|
||||
rows: SeguimientoSyncRow[]
|
||||
): Promise<number> {
|
||||
if (!rows.length) return 0;
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/seguimiento_entries?on_conflict=tenant_id,source,external_id`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(rows),
|
||||
}
|
||||
);
|
||||
if (!r.ok) {
|
||||
throw new Error(`upsert seguimiento: ${await r.text()}`);
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export async function patchSuperhumanIntegration(
|
||||
tenantId: string,
|
||||
patch: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const url = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${SUPERHUMAN_PROVIDER}`;
|
||||
const r = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
// Ensure row exists then retry
|
||||
await fetch(`${supabaseUrl()}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=ignore-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
provider: SUPERHUMAN_PROVIDER,
|
||||
status: 'disconnected',
|
||||
label: 'Superhuman Docs',
|
||||
}),
|
||||
});
|
||||
const r2 = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ ...patch, updated_at: new Date().toISOString() }),
|
||||
});
|
||||
if (!r2.ok) throw new Error(await r2.text());
|
||||
}
|
||||
}
|
||||
|
||||
export function docDisplayHint(name: string): string {
|
||||
return normalizeName(cleanClientDocName(name));
|
||||
}
|
||||
Reference in New Issue
Block a user