diff --git a/.env.example b/.env.example index 7787b88..d07b7b6 100644 --- a/.env.example +++ b/.env.example @@ -26,3 +26,7 @@ GOOGLE_ADS_REFRESH_TOKEN= # GOOGLE_ADS_CLIENT_ID= # GOOGLE_ADS_CLIENT_SECRET= # GOOGLE_ADS_LOGIN_CUSTOMER_ID= + +# Superhuman Docs (optional) +# SUPERHUMAN_DOCS_API_KEY= + diff --git a/README.md b/README.md index 2532a4e..ef9d1e7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Health: `GET /api/health` |------|-----| | `/login` | Supabase Auth | | `/dashboard/kpis` | KPIs | +| `/dashboard/seguimiento` | Timeline Superhuman + evolución canales | | `/dashboard/estrategia` | Targets + simulador | | `/dashboard/activos` | Docs aprobados (RLS) | | `/dashboard/creatives` | Creatividades (en curso) | diff --git a/scripts/dokploy_apply_seguimiento.py b/scripts/dokploy_apply_seguimiento.py new file mode 100644 index 0000000..b988fbc --- /dev/null +++ b/scripts/dokploy_apply_seguimiento.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Apply sql/seguimiento.sql via Dokploy supabase-db terminal.""" +from __future__ import annotations + +import base64 +import os +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SQL = ROOT / "sql" / "seguimiento.sql" +BASE = "https://dokploy.4ventures.es" + + +def env(key: str) -> str: + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text().splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(800) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000) + page.wait_for_timeout(800) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1000) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(400) + page.get_by_text("Terminal", exact=True).click() + page.wait_for_timeout(1000) + if page.locator("button", has_text="Bash").count(): + page.locator("button", has_text="Bash").first.click() + page.wait_for_timeout(2800) + if page.locator("textarea.xterm-helper-textarea").count(): + page.locator("textarea.xterm-helper-textarea").first.click() + + def run(cmd: str, wait: int = 800) -> None: + page.keyboard.type(cmd, delay=1) + page.keyboard.press("Enter") + page.wait_for_timeout(wait) + + run("rm -f /tmp/seg.b64 /tmp/seg.sql; echo READY", 1200) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/seg.b64", 400) + run( + "base64 -d /tmp/seg.b64 > /tmp/seg.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/seg.sql; echo EXIT:$?", + 14000, + ) + text = page.evaluate( + "() => (document.querySelector('.xterm-rows')||{}).innerText||''" + ) + browser.close() + ok = "EXIT:0" in text + print("SQL_OK", ok, flush=True) + print(text[-600:], flush=True) + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/seguimiento.sql b/sql/seguimiento.sql new file mode 100644 index 0000000..2b55570 --- /dev/null +++ b/sql/seguimiento.sql @@ -0,0 +1,87 @@ +-- Seguimiento: Superhuman Docs timeline + integration slot +-- Idempotent. Never stores passwords. + +alter table public.integrations drop constraint if exists integrations_provider_check; +alter table public.integrations + add constraint integrations_provider_check + check (provider in ( + 'meta_ads', + 'google_ads', + 'ga4', + 'search_console', + 'tag_manager', + 'clientify', + 'klaviyo', + 'mailchimp', + 'google_drive', + 'wordpress', + 'prestashop', + 'shopify', + 'instagram', + 'facebook', + 'tiktok', + 'linkedin', + 'superhuman_docs' + )); + +insert into public.integrations (tenant_id, provider, status, label) +select t.id, 'superhuman_docs', 'disconnected', 'Superhuman Docs' +from public.tenants t +on conflict (tenant_id, provider) do nothing; + +create table if not exists public.seguimiento_entries ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + source text not null default 'superhuman' + check (source in ('superhuman', 'manual')), + entry_type text not null default 'topic' + check (entry_type in ('meeting', 'topic', 'kpis', 'note')), + external_id text not null, + title text not null, + summary text, + body_md text, + browser_link text, + metrics jsonb not null default '{}'::jsonb, + occurred_at timestamptz, + synced_at timestamptz not null default now(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, source, external_id) +); + +create index if not exists seguimiento_entries_tenant_occurred_idx + on public.seguimiento_entries (tenant_id, occurred_at desc nulls last); + +create index if not exists seguimiento_entries_tenant_synced_idx + on public.seguimiento_entries (tenant_id, synced_at desc); + +alter table public.seguimiento_entries enable row level security; + +drop policy if exists seguimiento_entries_tenant_select on public.seguimiento_entries; +drop policy if exists seguimiento_entries_tenant_write on public.seguimiento_entries; + +create policy seguimiento_entries_tenant_select on public.seguimiento_entries + for select using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +-- Clients read-only via RLS; agency writes with service role. +create policy seguimiento_entries_tenant_write on public.seguimiento_entries + for all using ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ) + with check ( + tenant_id::text = coalesce( + auth.jwt() -> 'app_metadata' ->> 'tenant_id', + auth.jwt() -> 'user_metadata' ->> 'tenant_id' + ) + ); + +comment on table public.seguimiento_entries is + 'Client-facing seguimiento timeline (Superhuman Docs). No passwords.'; diff --git a/src/app/api/admin/superhuman/sync/route.ts b/src/app/api/admin/superhuman/sync/route.ts new file mode 100644 index 0000000..6655a3d --- /dev/null +++ b/src/app/api/admin/superhuman/sync/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/seguimiento/route.ts b/src/app/api/seguimiento/route.ts new file mode 100644 index 0000000..4ae479a --- /dev/null +++ b/src/app/api/seguimiento/route.ts @@ -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; + 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( + `/rest/v1/seguimiento_entries?tenant_id=eq.${tid}&select=*&order=occurred_at.desc.nullslast&limit=80` + ), + restGet( + `/rest/v1/kpi_weekly?tenant_id=eq.${tid}&select=*&order=week.asc` + ), + restGet( + `/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), + }, + }); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 7cbc37b..88a9a2a 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -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({ Métricas + + + Seguimiento + ; +} diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx index 8191605..a4d99fd 100644 --- a/src/components/AgencyPortfolioBoard.tsx +++ b/src/components/AgencyPortfolioBoard.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import DriveConnectPanel from '@/components/DriveConnectPanel'; import AdsDiscoverPanel from '@/components/AdsDiscoverPanel'; +import SuperhumanSyncPanel from '@/components/SuperhumanSyncPanel'; import { AlertTriangle, Bell, @@ -181,6 +182,7 @@ export default function AgencyPortfolioBoard() {
+ {warning && (

diff --git a/src/components/SeguimientoBoard.tsx b/src/components/SeguimientoBoard.tsx new file mode 100644 index 0000000..750805c --- /dev/null +++ b/src/components/SeguimientoBoard.tsx @@ -0,0 +1,511 @@ +'use client'; + +import React, { useEffect, useMemo, useState } from 'react'; +import { + ExternalLink, + LineChart as LineIcon, + Loader2, + MessageSquareText, + Radio, +} from 'lucide-react'; +import { + ChannelWeekPoint, + formatEur, + formatNum, +} from '@/lib/metrics'; + +type Entry = { + id: string; + entry_type: string; + title: string; + summary?: string | null; + body_md?: string | null; + browser_link?: string | null; + metrics?: { + ventas?: number; + cpl?: number; + cac?: number; + periodo?: string; + }; + occurred_at?: string | null; +}; + +type Payload = { + label: string; + portfolioWide?: boolean; + hint?: string; + entries: Entry[]; + channelSeries: { + weeks: string[]; + channels: string[]; + points: ChannelWeekPoint[]; + }; + dailySummary?: { days: number; spend: number; leads: number }; +}; + +type MetricKey = 'leads' | 'spend' | 'cpl'; + +const CHANNEL_COLORS = [ + '#E58A3C', + '#38BDF8', + '#34D399', + '#A78BFA', + '#F472B6', + '#FBBF24', + '#94A3B8', +]; + +function typeLabel(t: string) { + if (t === 'meeting') return 'Reunión'; + if (t === 'kpis') return 'KPIs'; + if (t === 'topic') return 'Tema'; + return 'Nota'; +} + +function typeTone(t: string) { + if (t === 'meeting') return 'bg-amber-500/15 text-amber-300 border-amber-500/30'; + if (t === 'kpis') return 'bg-sky-500/15 text-sky-300 border-sky-500/30'; + if (t === 'topic') return 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30'; + return 'bg-gray-500/15 text-gray-300 border-gray-500/30'; +} + +function ChannelEvolutionChart({ + weeks, + channels, + points, + metric, + activeChannels, +}: { + weeks: string[]; + channels: string[]; + points: ChannelWeekPoint[]; + metric: MetricKey; + activeChannels: Set; +}) { + const [hover, setHover] = useState<{ + week: string; + channel: string; + value: number; + x: number; + y: number; + } | null>(null); + + const width = 720; + const height = 260; + const pad = { t: 16, r: 16, b: 36, l: 44 }; + const innerW = width - pad.l - pad.r; + const innerH = height - pad.t - pad.b; + + const series = useMemo(() => { + return channels + .filter((c) => activeChannels.has(c)) + .map((channel, i) => { + const vals = weeks.map((week) => { + const p = points.find((x) => x.week === week && x.channel === channel); + return p ? Number(p[metric] || 0) : 0; + }); + return { + channel, + color: CHANNEL_COLORS[i % CHANNEL_COLORS.length], + vals, + }; + }); + }, [channels, activeChannels, weeks, points, metric]); + + const maxY = Math.max(1, ...series.flatMap((s) => s.vals)); + + const xAt = (i: number) => + pad.l + (weeks.length <= 1 ? innerW / 2 : (i / (weeks.length - 1)) * innerW); + const yAt = (v: number) => pad.t + innerH - (v / maxY) * innerH; + + if (!weeks.length || !series.length) { + return ( +

+ Aún no hay series de canal en kpi_weekly para este cliente. +
+ ); + } + + return ( +
+ + {[0, 0.25, 0.5, 0.75, 1].map((t) => { + const y = pad.t + innerH * (1 - t); + const val = maxY * t; + return ( + + + + {metric === 'spend' || metric === 'cpl' + ? formatNum(val) + : formatNum(val)} + + + ); + })} + + {series.map((s) => { + const d = s.vals + .map((v, i) => `${i === 0 ? 'M' : 'L'} ${xAt(i)} ${yAt(v)}`) + .join(' '); + return ( + + + {s.vals.map((v, i) => ( + + setHover({ + week: weeks[i], + channel: s.channel, + value: v, + x: xAt(i), + y: yAt(v), + }) + } + onMouseLeave={() => setHover(null)} + /> + ))} + + ); + })} + + {weeks.map((w, i) => ( + + {w.slice(5)} + + ))} + + + {hover && ( +
+

{hover.channel}

+

{hover.week}

+

+ {metric === 'spend' + ? formatEur(hover.value) + : metric === 'cpl' + ? `${formatNum(hover.value)} € CPL` + : `${formatNum(hover.value)} leads`} +

+
+ )} +
+ ); +} + +export default function SeguimientoBoard() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [metric, setMetric] = useState('leads'); + const [activeChannels, setActiveChannels] = useState>(new Set()); + const [openId, setOpenId] = useState(null); + const [query, setQuery] = useState(''); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + setError(null); + try { + const r = await fetch('/api/seguimiento', { cache: 'no-store' }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error || 'Error cargando seguimiento'); + if (!cancelled) { + setData(j); + setActiveChannels(new Set(j.channelSeries?.channels || [])); + } + } catch (e) { + if (!cancelled) { + setError(e instanceof Error ? e.message : 'Error'); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const filteredEntries = useMemo(() => { + const list = data?.entries || []; + const q = query.trim().toLowerCase(); + if (!q) return list; + return list.filter( + (e) => + e.title.toLowerCase().includes(q) || + (e.summary || '').toLowerCase().includes(q) || + (e.body_md || '').toLowerCase().includes(q) + ); + }, [data, query]); + + function toggleChannel(ch: string) { + setActiveChannels((prev) => { + const next = new Set(prev); + if (next.has(ch)) next.delete(ch); + else next.add(ch); + return next; + }); + } + + if (loading) { + return ( +
+ Cargando seguimiento… +
+ ); + } + + if (error) { + return ( +

+ {error} +

+ ); + } + + if (!data) return null; + + if (data.portfolioWide) { + return ( +
+ +

Seguimiento por cliente

+

+ {data.hint || + 'Selecciona un cliente en el encabezado para ver la evolución reunión a reunión y por canales.'} +

+
+ ); + } + + const { channelSeries, dailySummary } = data; + + return ( +
+
+
+

+ Seguimiento & evolución +

+

+ {data.label} · timeline Superhuman + series de canal (kpi_weekly) +

+
+ {dailySummary && dailySummary.days > 0 && ( +
+ Ads recientes · {formatEur(dailySummary.spend)} ·{' '} + {formatNum(dailySummary.leads)} leads · {dailySummary.days} días +
+ )} +
+ + {/* Channel evolution */} +
+
+
+ + Evolución por canal +
+
+ {( + [ + ['leads', 'Leads'], + ['spend', 'Inversión'], + ['cpl', 'CPL'], + ] as [MetricKey, string][] + ).map(([k, label]) => ( + + ))} +
+
+ +
+ {channelSeries.channels.map((ch, i) => { + const on = activeChannels.has(ch); + const color = CHANNEL_COLORS[i % CHANNEL_COLORS.length]; + return ( + + ); + })} +
+ + +
+ + {/* Timeline */} +
+
+
+ + Timeline reunión a reunión + + ({filteredEntries.length}) + +
+ setQuery(e.target.value)} + placeholder="Filtrar temas, CPL, Meta…" + className="w-full sm:w-64 rounded-lg bg-black/30 border border-gray-800 px-3 py-1.5 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-brand-500/50" + /> +
+ + {filteredEntries.length === 0 ? ( +
+ Sin entradas aún. Desde Agencia → sincroniza Superhuman Docs para este + cliente (páginas de Seguimiento; nunca contraseñas). +
+ ) : ( +
    + {filteredEntries.map((e) => { + const open = openId === e.id; + const when = e.occurred_at + ? new Date(e.occurred_at).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) + : '—'; + return ( +
  1. + + +
  2. + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/SuperhumanSyncPanel.tsx b/src/components/SuperhumanSyncPanel.tsx new file mode 100644 index 0000000..5670aa7 --- /dev/null +++ b/src/components/SuperhumanSyncPanel.tsx @@ -0,0 +1,178 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { Loader2, RefreshCw, FileText } from 'lucide-react'; + +type Match = { + docId: string; + docName: string; + tenantId: string | null; + tenantName: string | null; + score: number; + status: string; +}; + +type SyncResult = { + tenantName: string; + status: string; + upserted: number; + docName: string | null; + error?: string; +}; + +export default function SuperhumanSyncPanel() { + const [matches, setMatches] = useState([]); + const [docCount, setDocCount] = useState(0); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [message, setMessage] = useState(null); + const [results, setResults] = useState([]); + + async function loadStatus() { + setLoading(true); + setMessage(null); + try { + const r = await fetch('/api/admin/superhuman/sync', { cache: 'no-store' }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error || 'No se pudo leer Superhuman'); + setMatches(j.matches || []); + setDocCount(j.docCount || 0); + } catch (e) { + setMessage(e instanceof Error ? e.message : 'Error'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadStatus(); + }, []); + + async function runSync() { + setSyncing(true); + setMessage(null); + try { + const r = await fetch('/api/admin/superhuman/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ exportBodies: true, maxEntries: 20 }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.error || 'Sync falló'); + setResults(j.results || []); + setMessage( + `Sincronizados ${j.synced}/${(j.results || []).length} tenants (sin contraseñas).` + ); + await loadStatus(); + } catch (e) { + setMessage(e instanceof Error ? e.message : 'Error'); + } finally { + setSyncing(false); + } + } + + const matched = matches.filter((m) => m.status === 'matched').length; + + return ( +
+
+
+

+ + Superhuman Docs → Seguimiento +

+

+ Importa páginas de Seguimiento/KPIs por cliente. Excluye CONTRASEÑAS. +

+
+
+ + +
+
+ +

+ {docCount} docs · {matched} matched a tenants del portal +

+ + {message && ( +

+ {message} +

+ )} + + {results.length > 0 && ( +
    + {results.map((r) => ( +
  • + {r.tenantName} · {r.status} + {r.docName ? ` · ${r.docName}` : ''} + {r.upserted ? ` · ${r.upserted} entradas` : ''} + {r.error ? ` · ${r.error}` : ''} +
  • + ))} +
+ )} + + {!loading && matches.length > 0 && ( +
+ + + + + + + + + + {matches.slice(0, 20).map((m) => ( + + + + + + ))} + +
TenantDocMatch
+ {m.tenantName || '—'} + + {m.docName || '—'} + + + {m.status} + {m.score ? ` ${m.score}` : ''} + +
+
+ )} +
+ ); +} diff --git a/src/lib/agency.ts b/src/lib/agency.ts index f9589ff..9b91466 100644 --- a/src/lib/agency.ts +++ b/src/lib/agency.ts @@ -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 = diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 320be26..5f2d74e 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -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(); + 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 }; diff --git a/src/lib/superhuman-docs.ts b/src/lib/superhuman-docs.ts new file mode 100644 index 0000000..a00ee81 --- /dev/null +++ b/src/lib/superhuman-docs.ts @@ -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( + 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; + 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 { + 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)); +}