/** 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; const AGENCY_ONLY_RE = /\bp\s*&\s*l\b|\bp&l\b|rrhh|n[oó]mina|contrato|facturaci[oó]n interna/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( path: string, init?: RequestInit & { query?: Record } ): Promise { 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 { const out: ShDoc[] = []; let pageToken: string | undefined; do { const query: Record = { 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 { const out: ShPage[] = []; let pageToken: string | undefined; do { const query: Record = { 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 { 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 ): ShPage[] { const chain: ShPage[] = []; let cur: ShPage | undefined = page; const seen = new Set(); 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; if (AGENCY_ONLY_RE.test(page.name || '')) continue; const ancestors = pageAncestors(page, byId); if (ancestors.some((a) => isPasswordSensitiveName(a.name))) continue; if (ancestors.some((a) => AGENCY_ONLY_RE.test(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 { 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 { 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 ): Promise { 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)); }