diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 37fe88f..ccd04b4 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -47,6 +47,28 @@ Nunca pegar keys en PDFs ni `.env.example`. - `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM - OAuth live Meta; Google Ads via env; **GA4/GSC** vía OAuth marketing@ (reconectar Google en Agencia + sync orgánico) - IP allowlist Traefik: mantener IPs oficina actuales en `middleware-hardening.yml` +- **`CLIENTIFY_WEBHOOK_SECRET` obligatorio** — sin él el webhook responde 503 +- **Cron diario (obligatorio en prod)** — ver sección Cron + +## Cron diario (datos siempre frescos para vistas cliente) + +En Dokploy → Environment del `client-portal`: + +1. Define `CRON_SECRET` (string largo aleatorio). +2. Schedule diario ~06:00 UTC: + +```bash +curl -sS -H "Authorization: Bearer $CRON_SECRET" \ + "https://app.4ventures.es/api/cron/daily-sync?days=7" +``` + +Endpoints individuales (mismo secret): + +- `/api/cron/organic-sync` — GA4 + GSC → `organic_daily` +- `/api/cron/ads-sync` — Meta + Google Ads → `ad_daily` (API v22) +- `/api/cron/superhuman-sync` — Seguimiento / Google Docs + +`last_sync_at` solo se actualiza tras escribir filas reales (no al emparejar). ## Verificación @@ -54,3 +76,4 @@ Nunca pegar keys en PDFs ni `.env.example`. - Rol `client` → redirigido fuera de agencia - Chat/simulate solo con sesión - Health: `serviceRoleConfigured: true` +- Tras cron: Orgánico / Ads / Seguimiento con `last_sync_at` de hoy en ficha diff --git a/scripts/sync_ads_only.ts b/scripts/sync_ads_only.ts new file mode 100644 index 0000000..cb62e28 --- /dev/null +++ b/scripts/sync_ads_only.ts @@ -0,0 +1,75 @@ +/** + * Sync Meta + Google Ads → ad_daily (+ kpi_weekly rollup). + * + * Usage: + * npx tsx scripts/sync_ads_only.ts # last 30d all tenants + * DAYS=640 npx tsx scripts/sync_ads_only.ts # backfill ~from Jan 2025 + * TENANT_SLUG=bps DAYS=640 npx tsx ... + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; +config({ path: resolve(process.cwd(), '.env.local') }); + +import { + ADS_BACKFILL_MAX_DAYS, + syncAdsForTenants, +} from '../src/lib/ads-sync'; + +async function main() { + const slug = process.env.TENANT_SLUG?.trim(); + let tenantId: string | undefined; + if (slug) { + const { serviceHeaders, supabaseUrl } = await import('../src/lib/agency'); + const headers = serviceHeaders()!; + const r = await fetch( + `${supabaseUrl()}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`, + { headers } + ); + const rows = (await r.json()) as { id: string }[]; + tenantId = rows[0]?.id; + if (!tenantId) throw new Error(`Tenant slug not found: ${slug}`); + } + + // Days since 2025-01-01 (inclusive) when DAYS unset and BACKFILL=1 + const backfill = process.env.BACKFILL === '1' || process.env.BACKFILL === 'true'; + const daysFrom2025 = Math.ceil( + (Date.now() - Date.UTC(2025, 0, 1)) / 86400000 + ); + const rawDays = Number(process.env.DAYS); + const days = Math.min( + ADS_BACKFILL_MAX_DAYS, + Math.max( + 1, + Number.isFinite(rawDays) && rawDays > 0 + ? rawDays + : backfill + ? daysFrom2025 + : 30 + ) + ); + + console.log( + `Ads sync · days=${days} · since≈${new Date(Date.now() - days * 86400000).toISOString().slice(0, 10)}` + + (tenantId ? ` · tenant=${slug || tenantId}` : ' · all tenants') + ); + + const { results } = await syncAdsForTenants({ tenantId, days }); + let ok = 0; + let err = 0; + for (const x of results) { + const rows = x.metaRows + x.googleRows; + if (rows > 0) ok += 1; + if (x.errors.length) err += 1; + console.log( + `${x.tenantName}: meta=${x.metaRows} google=${x.googleRows} weekly=${x.weeklyRows}` + + (x.errors.length + ? ` · ERR ${x.errors.join('; ').slice(0, 200)}` + : '') + ); + } + console.log(`\nDone · synced=${ok} withErrors=${err} / ${results.length}`); +} +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/sync_all_data.ts b/scripts/sync_all_data.ts new file mode 100644 index 0000000..4bd739e --- /dev/null +++ b/scripts/sync_all_data.ts @@ -0,0 +1,272 @@ +/** + * One-shot sync: organic + ads + superhuman + SEO audits. + * Usage: + * npx tsx -r dotenv/config -r tsconfig-paths/register scripts/sync_all_data.ts + * Optional: TENANT_SLUG=bps + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(process.cwd(), '.env.local') }); +config({ path: resolve(process.cwd(), '.env') }); + +import { serviceHeaders, supabaseUrl } from '../src/lib/agency'; +import { syncAllConnectedOrganic } from '../src/lib/google-organic'; +import { syncAdsForTenants } from '../src/lib/ads-sync'; +import { syncAllSuperhumanTenants } from '../src/lib/superhuman-docs'; +import { runSeoSiteAudit } from '../src/lib/seo-audit'; +import { resolveMetaAccessTokens } from '../src/lib/ads-discovery'; + +const META = 'https://graph.facebook.com/v21.0'; + +type Tenant = { id: string; name: string; slug: string }; + +async function sbGet(path: string): Promise { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, { + headers, + cache: 'no-store', + }); + if (!r.ok) throw new Error(`${path}: ${(await r.text()).slice(0, 300)}`); + return (await r.json()) as T; +} + +async function sbPatch(path: string, body: unknown) { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`PATCH ${path}: ${(await r.text()).slice(0, 300)}`); +} + +async function sbPost(path: string, body: unknown) { + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`POST ${path}: ${(await r.text()).slice(0, 400)}`); +} + +async function syncSocial(tenants: Tenant[]) { + const tokens = await resolveMetaAccessTokens(); + const token = tokens[0]; + if (!token) return { ok: false as const, warning: 'Sin META token', results: [] as unknown[] }; + + const results: { tenant: string; fb?: string; ig?: string }[] = []; + + for (const tenant of tenants) { + const integs = await sbGet< + { + provider: string; + external_account_id?: string | null; + status: string; + meta?: Record | null; + }[] + >( + `integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=in.(facebook,instagram)&status=eq.connected&select=provider,external_account_id,status,meta` + ); + if (!integs.length) continue; + const out: { tenant: string; fb?: string; ig?: string } = { + tenant: tenant.name, + }; + + for (const i of integs) { + const id = i.external_account_id; + if (!id) continue; + try { + let payload: Record | null = null; + if (i.provider === 'facebook') { + const r = await fetch( + `${META}/${encodeURIComponent(id)}?fields=name,fan_count,followers_count&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (r.ok) { + payload = (await r.json()) as Record; + out.fb = 'ok'; + } else { + out.fb = `err ${(await r.text()).slice(0, 80)}`; + } + } else { + const r = await fetch( + `${META}/${encodeURIComponent(id)}?fields=name,followers_count,media_count&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (r.ok) { + payload = (await r.json()) as Record; + out.ig = 'ok'; + } else { + out.ig = `err ${(await r.text()).slice(0, 80)}`; + } + } + if (payload) { + const now = new Date().toISOString(); + await sbPatch( + `integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${i.provider}`, + { + last_sync_at: now, + error_message: null, + meta: { + ...(i.meta || {}), + social_cache: payload, + social_cached_at: now, + }, + } + ); + } + } catch (e) { + const msg = e instanceof Error ? e.message : 'err'; + if (i.provider === 'facebook') out.fb = msg; + else out.ig = msg; + } + } + results.push(out); + } + return { ok: true as const, results }; +} + +async function syncSeoAudits(tenants: Tenant[]) { + const results: { + tenant: string; + score?: number; + grade?: string; + error?: string; + }[] = []; + for (const tenant of tenants) { + const [gsc, ga4] = await Promise.all([ + sbGet< + { external_account_id?: string | null; meta?: { site_url?: string } }[] + >( + `integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.search_console&status=eq.connected&select=external_account_id,meta&limit=1` + ), + sbGet<{ id: string }[]>( + `integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.ga4&status=eq.connected&select=id&limit=1` + ), + ]); + const site = + gsc[0]?.meta?.site_url || gsc[0]?.external_account_id || null; + if (!site) continue; + try { + const audit = await runSeoSiteAudit({ + gscSiteUrl: site, + ga4Connected: ga4.length > 0, + gscConnected: true, + }); + await sbPost('seo_audits', { + tenant_id: tenant.id, + site_url: audit.siteUrl, + score: audit.score, + grade: audit.grade, + warning_count: audit.warningCount, + critical_count: audit.criticalCount, + checks: audit.checks, + summary: audit.summary, + }); + results.push({ + tenant: tenant.name, + score: audit.score, + grade: audit.grade, + }); + } catch (e) { + results.push({ + tenant: tenant.name, + error: e instanceof Error ? e.message.slice(0, 160) : 'audit error', + }); + } + } + return results; +} + +async function main() { + if (!serviceHeaders()) throw new Error('Falta SUPABASE_SERVICE_ROLE_KEY'); + + let tenants = await sbGet( + 'tenants?select=id,name,slug&order=name.asc' + ); + const slug = process.env.TENANT_SLUG?.trim(); + const tid = process.env.TENANT_ID?.trim(); + if (slug) tenants = tenants.filter((t) => t.slug === slug); + if (tid) tenants = tenants.filter((t) => t.id === tid); + + console.log(`\n=== SYNC ALL · ${tenants.length} tenant(s) ===\n`); + + console.log('--- ORGANIC ---'); + const organic = await syncAllConnectedOrganic({ + tenantId: tenants.length === 1 ? tenants[0].id : null, + days: 28, + }); + for (const r of organic.results) { + const name = tenants.find((t) => t.id === r.tenantId)?.name || r.tenantId; + console.log( + ` ${name}: ${r.daysWritten}d${r.warning ? ` · ⚠ ${r.warning}` : ''}` + ); + } + + console.log('\n--- ADS ---'); + const ads = await syncAdsForTenants({ + tenantId: tenants.length === 1 ? tenants[0].id : undefined, + days: 30, + }); + for (const r of ads.results) { + console.log( + ` ${r.tenantName}: meta=${r.metaRows} google=${r.googleRows}` + + (r.errors.length ? ` · ERR ${r.errors.join('; ').slice(0, 100)}` : '') + ); + } + + console.log('\n--- SUPERHUMAN ---'); + try { + const sh = await syncAllSuperhumanTenants({ + tenantId: tenants.length === 1 ? tenants[0].id : null, + maxEntries: 100, + exportBodies: true, + }); + for (const r of sh.results) { + console.log( + ` ${r.tenantName}: ${r.status}` + + (r.upserted ? ` · ${r.upserted}` : '') + + (r.docName ? ` · ${r.docName}` : '') + + (r.error ? ` · ${r.error.slice(0, 80)}` : '') + ); + } + } catch (e) { + console.log(' ', e instanceof Error ? e.message : e); + } + + console.log('\n--- SOCIAL ---'); + const social = await syncSocial(tenants); + if (!social.ok) console.log(' ', social.warning); + for (const r of social.results as { tenant: string; fb?: string; ig?: string }[]) { + console.log(` ${r.tenant}: fb=${r.fb || '-'} ig=${r.ig || '-'}`); + } + + console.log('\n--- SEO AUDIT ---'); + for (const r of await syncSeoAudits(tenants)) { + console.log( + ` ${r.tenant}:` + + (r.grade != null ? ` ${r.grade} (${r.score})` : '') + + (r.error ? ` ERR ${r.error}` : '') + ); + } + + console.log('\n=== DONE ===\n'); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/sync_clientify_tenant.py b/scripts/sync_clientify_tenant.py new file mode 100644 index 0000000..5926b8a --- /dev/null +++ b/scripts/sync_clientify_tenant.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +"""Sync Clientify contacts → leads and deals → deals for one tenant. + +Requires in .env.local: + CLIENTIFY_API_KEY (or CLIENTIFY_API_KEY_) + NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY + +Usage: + python3 scripts/sync_clientify_tenant.py --tenant-id a7a6eb49-... --label agua-miia +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +CFY = "https://api.clientify.com/v1" + +# Clientify deal status_desc → warehouse stage +STAGE_MAP = { + "won": "won", + "lost": "lost", + "open": "open", +} + + +def load_env() -> None: + for line in (ROOT / ".env.local").read_text().splitlines(): + if "=" in line and not line.startswith("#"): + k, v = line.split("=", 1) + k = k.strip() + if k and k not in os.environ: + os.environ[k] = v.strip().strip('"').strip("'") + + +def env_key(label: str) -> str: + slug = label.upper().replace("-", "_").replace(" ", "_") + return ( + os.environ.get(f"CLIENTIFY_API_KEY_{slug}") + or os.environ.get("CLIENTIFY_API_KEY") + or "" + ) + + +def sb_headers() -> dict[str, str]: + key = os.environ["SUPABASE_SERVICE_ROLE_KEY"] + return { + "apikey": key, + "Authorization": f"Bearer {key}", + "Content-Type": "application/json", + } + + +def sb(method: str, path: str, body: Any = None, prefer: str = "return=minimal"): + url = os.environ["NEXT_PUBLIC_SUPABASE_URL"].rstrip("/") + path + headers = dict(sb_headers()) + if prefer: + headers["Prefer"] = prefer + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=120) as r: + raw = r.read().decode() + return r.status, (json.loads(raw) if raw.strip() else None) + except urllib.error.HTTPError as e: + return e.code, e.read().decode()[:500] + + +def cfy_get(token: str, path: str) -> dict: + req = urllib.request.Request( + CFY + path, + headers={"Authorization": f"Token {token}", "Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read()) + + +def cfy_paginate(token: str, path: str, page_size: int = 100) -> list[dict]: + out: list[dict] = [] + # path may already have query + sep = "&" if "?" in path else "?" + url_path = f"{path}{sep}page_size={page_size}" + page = 1 + while True: + q = f"{url_path}&page={page}" if "page=" not in url_path else url_path + # rebuild clean + base, _, query = path.partition("?") + params = urllib.parse.parse_qs(query) + params["page_size"] = [str(page_size)] + params["page"] = [str(page)] + q = base + "?" + urllib.parse.urlencode({k: v[0] for k, v in params.items()}) + j = cfy_get(token, q) + batch = j.get("results") or [] + out.extend(batch) + print(f" {path} page={page} +{len(batch)} total={len(out)}/{j.get('count')}", flush=True) + if not j.get("next") or not batch: + break + page += 1 + return out + + +def first_email(c: dict) -> str | None: + emails = c.get("emails") or [] + if isinstance(emails, list) and emails: + e = emails[0] + if isinstance(e, dict): + return (e.get("email") or e.get("value") or "").strip() or None + return str(e).strip() or None + return None + + +def first_phone(c: dict) -> str | None: + phones = c.get("phones") or [] + if isinstance(phones, list) and phones: + p = phones[0] + if isinstance(p, dict): + return (p.get("phone") or p.get("value") or "").strip() or None + return str(p).strip() or None + return None + + +def contact_id_from_url(url: str | None) -> str | None: + if not url: + return None + # https://api.clientify.com/v1/contacts/12345/ + parts = url.rstrip("/").split("/") + return parts[-1] if parts else None + + +def map_stage(deal: dict) -> str: + desc = (deal.get("status_desc") or "").strip().lower() + if desc in STAGE_MAP: + return STAGE_MAP[desc] + st = deal.get("status") + # Observed: 1 open-ish, 3 won, 4 lost (API filter unreliable) + if st == 3 or desc == "won": + return "won" + if st == 4 or desc == "lost": + return "lost" + return "open" + + +def parse_date(s: str | None) -> str | None: + if not s: + return None + # date-only or iso + try: + if "T" in s: + return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat() + return datetime.fromisoformat(s + "T12:00:00+00:00").astimezone(timezone.utc).isoformat() + except Exception: + return None + + +def main() -> int: + load_env() + ap = argparse.ArgumentParser() + ap.add_argument("--tenant-id", required=True) + ap.add_argument("--label", default="tenant") + ap.add_argument("--contacts-only", action="store_true") + ap.add_argument("--deals-only", action="store_true") + args = ap.parse_args() + + token = env_key(args.label) + if not token: + print("missing CLIENTIFY_API_KEY", file=sys.stderr) + return 2 + + tid = args.tenant_id + print(f"Sync Clientify → warehouse tenant={tid} label={args.label}", flush=True) + + do_contacts = not args.deals_only + do_deals = not args.contacts_only + + contact_to_lead: dict[str, str] = {} + + if do_contacts: + print("Fetching contacts…", flush=True) + contacts = cfy_paginate(token, "/contacts/") + # wipe previous clientify leads for tenant + st, _ = sb( + "DELETE", + f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify", + ) + print(f"DELETE leads → {st}", flush=True) + + rows = [] + for c in contacts: + cid = str(c["id"]) + lid = str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-lead:{tid}:{cid}")) + contact_to_lead[cid] = lid + source = ( + (c.get("contact_source") or c.get("channel") or c.get("medium") or "clientify") + ) + if isinstance(source, str): + source = source.strip().lower()[:80] or "clientify" + else: + source = "clientify" + status = (c.get("status") or "new") + if isinstance(status, str): + status = status.strip().lower()[:40] or "new" + else: + status = "new" + rows.append( + { + "id": lid, + "tenant_id": tid, + "first_name": ( + " ".join( + x + for x in [(c.get("first_name") or "").strip(), (c.get("last_name") or "").strip()] + if x + ) + or None + ), + "email": first_email(c), + "phone": first_phone(c), + "source": source, + "medium": (c.get("medium") or None), + "crm": "clientify", + "crm_contact_id": cid, + "status": status, + "created_at": parse_date(c.get("created")) + or datetime.now(timezone.utc).isoformat(), + } + ) + + ok = 0 + for i in range(0, len(rows), 200): + chunk = rows[i : i + 200] + st, err = sb("POST", "/rest/v1/leads", chunk) + if st not in (200, 201): + print("FAIL leads", st, err, flush=True) + return 1 + ok += len(chunk) + print(f" INSERT leads {ok}/{len(rows)}", flush=True) + print(f"OK contacts→leads {ok}", flush=True) + + if do_deals: + if not contact_to_lead and do_contacts is False: + # load existing map + st, data = sb( + "GET", + f"/rest/v1/leads?tenant_id=eq.{tid}&crm=eq.clientify&select=id,crm_contact_id&limit=20000", + prefer="", + ) + if st == 200 and isinstance(data, list): + for r in data: + if r.get("crm_contact_id"): + contact_to_lead[str(r["crm_contact_id"])] = r["id"] + + print("Fetching deals…", flush=True) + deals = cfy_paginate(token, "/deals/") + st, _ = sb( + "DELETE", + f"/rest/v1/deals?tenant_id=eq.{tid}&crm_deal_id=like.clientify:*", + ) + print(f"DELETE deals → {st}", flush=True) + + rows = [] + by_stage = {"won": 0, "lost": 0, "open": 0} + for d in deals: + did = str(d["id"]) + stage = map_stage(d) + by_stage[stage] = by_stage.get(stage, 0) + 1 + cid = contact_id_from_url(d.get("contact")) + lead_id = contact_to_lead.get(cid) if cid else None + amount = float(d.get("amount") or d.get("amount_user") or 0) + closed = parse_date(d.get("actual_closed_date")) + row = { + "id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"clientify-deal:{tid}:{did}")), + "tenant_id": tid, + "lead_id": lead_id, + "crm_deal_id": f"clientify:{did}", + "stage": stage, + "amount": amount, + "won_at": closed if stage == "won" else None, + "lost_at": closed if stage == "lost" else None, + } + rows.append(row) + + ok = 0 + for i in range(0, len(rows), 200): + chunk = rows[i : i + 200] + st, err = sb("POST", "/rest/v1/deals", chunk) + if st not in (200, 201): + print("FAIL deals", st, err, flush=True) + return 1 + ok += len(chunk) + print(f" INSERT deals {ok}/{len(rows)}", flush=True) + won_amt = sum(r["amount"] for r in rows if r["stage"] == "won") + print(f"OK deals {ok} by_stage={by_stage} won_amount={won_amt:.2f}", flush=True) + + # touch integration + now = datetime.now(timezone.utc).isoformat() + sb( + "PATCH", + f"/rest/v1/integrations?tenant_id=eq.{tid}&provider=eq.clientify", + {"status": "connected", "last_sync_at": now, "error_message": None}, + ) + print("DONE", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/leads_status_timing.sql b/sql/leads_status_timing.sql new file mode 100644 index 0000000..b09cec8 --- /dev/null +++ b/sql/leads_status_timing.sql @@ -0,0 +1,22 @@ +-- CRM date correctness: preserve lead birth vs status change timing. +-- Run in Supabase SQL editor (service role / dashboard). + +alter table public.leads + add column if not exists updated_at timestamptz; + +alter table public.leads + add column if not exists status_changed_at timestamptz; + +comment on column public.leads.created_at is + 'When the contact entered Clientify / first seen (alta).'; +comment on column public.leads.updated_at is + 'Last webhook/sync touch.'; +comment on column public.leads.status_changed_at is + 'When status last changed (e.g. → cliente). Use this for “ventas en el periodo”.'; + +-- Backfill: unknown status timing → treat as created_at (best effort). +update public.leads +set + updated_at = coalesce(updated_at, created_at, now()), + status_changed_at = coalesce(status_changed_at, created_at, now()) +where updated_at is null or status_changed_at is null; diff --git a/src/app/api/admin/ads/sync/route.ts b/src/app/api/admin/ads/sync/route.ts index b26007d..159997c 100644 --- a/src/app/api/admin/ads/sync/route.ts +++ b/src/app/api/admin/ads/sync/route.ts @@ -20,7 +20,7 @@ export async function POST(req: NextRequest) { const tenantId = typeof body.tenantId === 'string' ? body.tenantId : undefined; const days = - typeof body.days === 'number' && body.days > 0 && body.days <= 90 + typeof body.days === 'number' && body.days > 0 && body.days <= 1000 ? body.days : 30; diff --git a/src/app/api/admin/superhuman/sync/route.ts b/src/app/api/admin/superhuman/sync/route.ts index ca65a76..1f498b9 100644 --- a/src/app/api/admin/superhuman/sync/route.ts +++ b/src/app/api/admin/superhuman/sync/route.ts @@ -7,14 +7,8 @@ import { userRole, } from '@/lib/agency'; import { - SUPERHUMAN_PROVIDER, - bestDocForTenant, - buildSeguimientoRowsForDoc, - listDocs, - matchDocsToTenants, - patchSuperhumanIntegration, + syncAllSuperhumanTenants, superhumanConfigured, - upsertSeguimientoRows, } from '@/lib/superhuman-docs'; export const dynamic = 'force-dynamic'; @@ -51,6 +45,9 @@ export async function GET() { if ('error' in gate && gate.error) return gate.error; try { + const { listDocs, matchDocsToTenants, SUPERHUMAN_PROVIDER } = await import( + '@/lib/superhuman-docs' + ); const headers = (gate as { headers: { apikey: string; Authorization: string } }) .headers; const [docs, tenantsRes] = await Promise.all([ @@ -91,8 +88,6 @@ export async function GET() { 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 @@ -102,128 +97,14 @@ export async function POST(req: NextRequest) { const maxEntries = Math.min(Number(body.maxEntries) || 80, 150); 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, - tenantName: tenant.name, - 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, - }); - } - } - + const result = await syncAllSuperhumanTenants({ + tenantId: onlyTenant || null, + maxEntries, + exportBodies, + }); return NextResponse.json({ ok: true, - results, - synced: results.filter((r) => r.status === 'synced').length, + ...result, }); } catch (e) { return NextResponse.json( diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index a272fc0..b7ad457 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -310,7 +310,7 @@ export async function POST(req: NextRequest) { `${i + 1}. **${c.campaign_name}** (${c.platform.toUpperCase()}):\n - Inversión: **${Number(c.spend || 0).toFixed(2)} €**\n - Clics: ${c.clicks || 0} (CTR: ${c.impressions > 0 ? ((c.clicks / c.impressions) * 100).toFixed(2) : 0}%)\n - Leads Captados: **${c.leads || 0}**` ).join('\n\n'); - return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: Datos diarios procesados vía Windsor.ai.*`); + return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: ad_daily (Meta + Google Ads sincronizados en el portal).*`); } else { return reply('📢 Actualmente no constan campañas activas con inversión registrada en los últimos 30 días para este portal.'); } @@ -347,7 +347,7 @@ export async function POST(req: NextRequest) { `📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}` ).join('\n\n'); - return reply(`📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Búsqueda híbrida vectorial (pgvector) y BM25 en document_chunks.*`); + return reply(`📚 **Fragmentos de la Biblioteca de Activos:**\n\n${excerpts}\n\n*Fuente: document_chunks del tenant (búsqueda en portal).*`); } } diff --git a/src/app/api/cron/ads-sync/route.ts b/src/app/api/cron/ads-sync/route.ts new file mode 100644 index 0000000..dd2b56f --- /dev/null +++ b/src/app/api/cron/ads-sync/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { assertCronAuth } from '@/lib/cron-auth'; +import { syncAdsForTenants } from '@/lib/ads-sync'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +/** + * Daily Meta + Google Ads → ad_daily. + * Auth: Authorization: Bearer $CRON_SECRET + */ +export async function GET(req: NextRequest) { + const denied = assertCronAuth(req); + if (denied) return denied; + + const days = Math.min( + 90, + Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7) + ); + + try { + const { results, days: d } = await syncAdsForTenants({ days }); + const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length; + const withErrors = results.filter((r) => r.errors.length).length; + return NextResponse.json({ + ok: true, + days: d, + synced: ok, + withErrors, + results, + at: new Date().toISOString(), + }); + } catch (e) { + return NextResponse.json( + { ok: false, error: e instanceof Error ? e.message : 'sync failed' }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + return GET(req); +} diff --git a/src/app/api/cron/daily-sync/route.ts b/src/app/api/cron/daily-sync/route.ts new file mode 100644 index 0000000..46e30d2 --- /dev/null +++ b/src/app/api/cron/daily-sync/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { assertCronAuth } from '@/lib/cron-auth'; +import { syncAllConnectedOrganic } from '@/lib/google-organic'; +import { getAgencyOAuthStatus } from '@/lib/google-drive'; +import { syncAdsForTenants } from '@/lib/ads-sync'; +import { + syncAllSuperhumanTenants, + superhumanConfigured, +} from '@/lib/superhuman-docs'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +/** + * Master daily job: organic + ads + superhuman. + * Dokploy (daily ~06:00 UTC): + * curl -sS -H "Authorization: Bearer $CRON_SECRET" \ + * "https://app.4ventures.es/api/cron/daily-sync" + */ +export async function GET(req: NextRequest) { + const denied = assertCronAuth(req); + if (denied) return denied; + + const days = Math.min( + 28, + Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7) + ); + + const out: Record = { + ok: true, + days, + at: new Date().toISOString(), + }; + + // 1) Organic + try { + const oauth = await getAgencyOAuthStatus(); + if (!oauth.connected) { + out.organic = { ok: false, warning: 'Google agencia no conectado' }; + } else { + out.organic = { + ok: true, + ...(await syncAllConnectedOrganic({ days })), + }; + } + } catch (e) { + out.organic = { + ok: false, + error: e instanceof Error ? e.message : 'organic failed', + }; + } + + // 2) Ads + try { + const ads = await syncAdsForTenants({ days }); + out.ads = { + ok: true, + synced: ads.results.filter((r) => r.metaRows + r.googleRows > 0).length, + withErrors: ads.results.filter((r) => r.errors.length).length, + results: ads.results, + }; + } catch (e) { + out.ads = { + ok: false, + error: e instanceof Error ? e.message : 'ads failed', + }; + } + + // 3) Superhuman + try { + if (!superhumanConfigured()) { + out.superhuman = { + ok: false, + warning: 'SUPERHUMAN_DOCS_API_KEY no configurado', + }; + } else { + out.superhuman = { + ok: true, + ...(await syncAllSuperhumanTenants({ + maxEntries: 80, + exportBodies: true, + })), + }; + } + } catch (e) { + out.superhuman = { + ok: false, + error: e instanceof Error ? e.message : 'superhuman failed', + }; + } + + const failed = ['organic', 'ads', 'superhuman'].some( + (k) => (out[k] as { ok?: boolean } | undefined)?.ok === false + ); + return NextResponse.json(out, { status: failed ? 207 : 200 }); +} + +export async function POST(req: NextRequest) { + return GET(req); +} diff --git a/src/app/api/cron/organic-sync/route.ts b/src/app/api/cron/organic-sync/route.ts index b25c0db..8db9e41 100644 --- a/src/app/api/cron/organic-sync/route.ts +++ b/src/app/api/cron/organic-sync/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { assertCronAuth } from '@/lib/cron-auth'; import { syncAllConnectedOrganic } from '@/lib/google-organic'; import { getAgencyOAuthStatus } from '@/lib/google-drive'; @@ -6,20 +7,12 @@ export const dynamic = 'force-dynamic'; export const maxDuration = 300; /** - * Daily (or any schedule) organic sync for all connected tenants. + * Daily organic sync for all connected tenants. * Auth: Authorization: Bearer $CRON_SECRET OR ?secret=$CRON_SECRET - * Dokploy cron example (daily 06:15 UTC): - * curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync */ export async function GET(req: NextRequest) { - const secret = process.env.CRON_SECRET?.trim(); - const auth = req.headers.get('authorization') || ''; - const q = req.nextUrl.searchParams.get('secret') || ''; - const token = auth.startsWith('Bearer ') ? auth.slice(7) : q; - - if (!secret || token !== secret) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const denied = assertCronAuth(req); + if (denied) return denied; const status = await getAgencyOAuthStatus(); if (!status.connected) { @@ -29,7 +22,6 @@ export async function GET(req: NextRequest) { }); } - // Rolling 7 days is enough daily; full history already filled by first sync const days = Math.min( 28, Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7) diff --git a/src/app/api/cron/superhuman-sync/route.ts b/src/app/api/cron/superhuman-sync/route.ts new file mode 100644 index 0000000..9fea18a --- /dev/null +++ b/src/app/api/cron/superhuman-sync/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { assertCronAuth } from '@/lib/cron-auth'; +import { + syncAllSuperhumanTenants, + superhumanConfigured, +} from '@/lib/superhuman-docs'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +/** + * Daily Superhuman / Seguimiento sync for all tenants. + * Auth: Authorization: Bearer $CRON_SECRET + */ +export async function GET(req: NextRequest) { + const denied = assertCronAuth(req); + if (denied) return denied; + + if (!superhumanConfigured()) { + return NextResponse.json({ + ok: false, + warning: 'SUPERHUMAN_DOCS_API_KEY no configurado', + }); + } + + try { + const result = await syncAllSuperhumanTenants({ + maxEntries: 100, + exportBodies: true, + }); + return NextResponse.json({ + ok: true, + ...result, + at: new Date().toISOString(), + }); + } catch (e) { + return NextResponse.json( + { ok: false, error: e instanceof Error ? e.message : 'sync failed' }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + return GET(req); +} diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts index eeac64c..d93ed54 100644 --- a/src/app/api/social/route.ts +++ b/src/app/api/social/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; -import { isAgencyRole, userRole } from '@/lib/agency'; +import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; import { resolveMetaAccessTokens } from '@/lib/ads-discovery'; export const dynamic = 'force-dynamic'; @@ -18,6 +18,73 @@ type Integ = { meta?: Record | null; }; +async function cacheSocialSnapshot( + tenantId: string, + provider: 'facebook' | 'instagram', + payload: unknown +) { + const headers = serviceHeaders(); + if (!headers) return; + const now = new Date().toISOString(); + // Merge into meta without wiping other keys: read-modify-write + const r = await fetch( + `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}&select=meta&limit=1`, + { headers, cache: 'no-store' } + ); + let meta: Record = {}; + if (r.ok) { + const rows = (await r.json()) as { meta?: Record }[]; + meta = { ...(rows[0]?.meta || {}) }; + } + // Keep a compact snapshot (metrics + up to 12 posts) + const snap = + payload && typeof payload === 'object' + ? { + ...(payload as object), + posts: Array.isArray((payload as { posts?: unknown }).posts) + ? (payload as { posts: unknown[] }).posts.slice(0, 12) + : undefined, + } + : payload; + meta.social_cache = snap; + meta.social_cached_at = now; + await fetch( + `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + meta, + last_sync_at: now, + error_message: null, + }), + } + ); +} + +function readSocialCache(integrations: Integ[]): { + facebook?: unknown; + instagram?: unknown; + at?: string; +} { + const out: { facebook?: unknown; instagram?: unknown; at?: string } = {}; + for (const i of integrations) { + const cache = i.meta?.social_cache; + const at = typeof i.meta?.social_cached_at === 'string' + ? i.meta.social_cached_at + : i.last_sync_at || undefined; + if (!cache) continue; + if (i.provider === 'facebook') out.facebook = cache; + if (i.provider === 'instagram') out.instagram = cache; + if (at && (!out.at || at > out.at)) out.at = at; + } + return out; +} + type SocialPost = { id: string; platform: 'facebook' | 'instagram'; @@ -585,25 +652,34 @@ export async function GET(req: NextRequest) { const posts: SocialPost[] = []; const tokens = await resolveMetaAccessTokens(); + const fbInteg = integrations.find( + (i) => + i.provider === 'facebook' && + i.status === 'connected' && + i.external_account_id && + !i.external_account_id.startsWith('http') + ); + const igInteg = integrations.find( + (i) => + i.provider === 'instagram' && + i.status === 'connected' && + i.external_account_id && + !i.external_account_id.startsWith('http') + ); + if (!tokens.length) { metrics.warning = 'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.'; + const cached = readSocialCache(integrations); + if (cached.facebook) metrics.facebook = cached.facebook; + if (cached.instagram) metrics.instagram = cached.instagram; + if (cached.facebook || cached.instagram) { + metrics.cached = true; + metrics.cachedAt = cached.at; + metrics.warning += + ' Mostrando último snapshot cacheado.'; + } } else { - const fbInteg = integrations.find( - (i) => - i.provider === 'facebook' && - i.status === 'connected' && - i.external_account_id && - !i.external_account_id.startsWith('http') - ); - const igInteg = integrations.find( - (i) => - i.provider === 'instagram' && - i.status === 'connected' && - i.external_account_id && - !i.external_account_id.startsWith('http') - ); - const token = (await pickWorkingToken(tokens, fbInteg?.external_account_id)) || (await pickWorkingToken(tokens, igInteg?.external_account_id)) || @@ -621,6 +697,7 @@ export async function GET(req: NextRequest) { if ('posts' in fb && Array.isArray(fb.posts)) { posts.push(...(fb.posts as SocialPost[])); } + await cacheSocialSnapshot(ctx.tenantId, 'facebook', fb); } if (igInteg?.external_account_id) { const ig = await fetchIgBundle( @@ -633,9 +710,18 @@ export async function GET(req: NextRequest) { if ('posts' in ig && Array.isArray(ig.posts)) { posts.push(...(ig.posts as SocialPost[])); } + await cacheSocialSnapshot(ctx.tenantId, 'instagram', ig); } } catch (e) { metrics.warning = e instanceof Error ? e.message : 'metrics error'; + const cached = readSocialCache(integrations); + if (cached.facebook && !metrics.facebook) metrics.facebook = cached.facebook; + if (cached.instagram && !metrics.instagram) + metrics.instagram = cached.instagram; + if (cached.facebook || cached.instagram) { + metrics.cached = true; + metrics.cachedAt = cached.at; + } } } diff --git a/src/app/api/webhooks/clientify/route.ts b/src/app/api/webhooks/clientify/route.ts index 9add863..2fba607 100644 --- a/src/app/api/webhooks/clientify/route.ts +++ b/src/app/api/webhooks/clientify/route.ts @@ -110,8 +110,13 @@ async function resolveTenantId(slugOrId: string | null): Promise const base = supabaseUrl(); // UUID? if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId; + // Legacy slug aliases (renames) + const SLUG_ALIASES: Record = { + herencias: 'fbm-abogados', + }; + const slug = SLUG_ALIASES[slugOrId.toLowerCase()] || slugOrId; const r = await fetch( - `${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slugOrId)}&select=id&limit=1`, + `${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`, { headers, cache: 'no-store' } ); if (!r.ok) return null; @@ -130,7 +135,59 @@ async function upsertLead(tenantId: string, c: Json) { str(c.channel, 80) || str(c.medium, 80) || 'clientify'; - const row = { + const newStatus = (str(c.status, 40) || 'new').toLowerCase(); + const now = new Date().toISOString(); + const base = supabaseUrl(); + + // Preserve alta date across webhook re-writes (DELETE+INSERT). + let prevCreated: string | null = null; + let prevStatus: string | null = null; + let prevStatusChanged: string | null = null; + const prevR = await fetch( + `${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status,status_changed_at&limit=1`, + { headers, cache: 'no-store' } + ); + if (prevR.ok) { + const rows = (await prevR.json()) as { + created_at?: string; + status?: string; + status_changed_at?: string | null; + }[]; + if (rows[0]) { + prevCreated = rows[0].created_at || null; + prevStatus = (rows[0].status || '').toLowerCase() || null; + prevStatusChanged = rows[0].status_changed_at || null; + } + } else { + // status_changed_at may not exist yet — retry without it + const prevR2 = await fetch( + `${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status&limit=1`, + { headers, cache: 'no-store' } + ); + if (prevR2.ok) { + const rows = (await prevR2.json()) as { + created_at?: string; + status?: string; + }[]; + if (rows[0]) { + prevCreated = rows[0].created_at || null; + prevStatus = (rows[0].status || '').toLowerCase() || null; + } + } + } + + const fromCfy = + str(c.created, 40) || + str(c.created_at, 40) || + str(c.date_created, 40); + let createdAt = prevCreated || fromCfy || now; + // Normalize date-only + if (createdAt.length === 10) createdAt = `${createdAt}T12:00:00.000Z`; + + const statusChanged = + !prevStatus || prevStatus !== newStatus ? now : prevStatusChanged || createdAt; + + const row: Record = { id, tenant_id: tenantId, first_name: @@ -142,19 +199,45 @@ async function upsertLead(tenantId: string, c: Json) { medium: str(c.medium, 80), crm: 'clientify', crm_contact_id: cid, - status: (str(c.status, 40) || 'new').toLowerCase(), + status: newStatus, + created_at: createdAt, + updated_at: now, + status_changed_at: statusChanged, }; - // Prefer upsert on crm_contact_id if unique; else delete+insert - const base = supabaseUrl(); + await fetch( `${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}`, { method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } } ); - const ins = await fetch(`${base}/rest/v1/leads`, { + + let ins = await fetch(`${base}/rest/v1/leads`, { method: 'POST', - headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' }, + headers: { + ...headers, + Prefer: 'return=minimal', + 'Content-Type': 'application/json', + }, body: JSON.stringify(row), }); + // Schema not migrated yet: drop timing columns and retry + if (!ins.ok) { + const err = await ins.text(); + if (err.includes('updated_at') || err.includes('status_changed_at')) { + delete row.updated_at; + delete row.status_changed_at; + ins = await fetch(`${base}/rest/v1/leads`, { + method: 'POST', + headers: { + ...headers, + Prefer: 'return=minimal', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(row), + }); + } else { + throw new Error(`lead insert ${ins.status}: ${err.slice(0, 200)}`); + } + } if (!ins.ok) { throw new Error(`lead insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`); } @@ -218,7 +301,7 @@ export async function GET(req: NextRequest) { service: 'clientify-webhook', tenant: tenant || null, hint: 'POST events from Clientify. Use ?tenant=', - example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia', + example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=&secret=…', }); } @@ -226,7 +309,17 @@ export async function POST(req: NextRequest) { const secretEnv = process.env.CLIENTIFY_WEBHOOK_SECRET?.trim(); const secretQ = req.nextUrl.searchParams.get('secret'); const secretH = req.headers.get('x-clientify-secret'); - if (secretEnv && secretQ !== secretEnv && secretH !== secretEnv) { + // Obligatorio: sin secreto el webhook es inyectable desde fuera + if (!secretEnv) { + return NextResponse.json( + { + error: 'webhook_not_configured', + hint: 'Define CLIENTIFY_WEBHOOK_SECRET en Dokploy', + }, + { status: 503 } + ); + } + if (secretQ !== secretEnv && secretH !== secretEnv) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); } @@ -238,7 +331,7 @@ export async function POST(req: NextRequest) { return NextResponse.json( { error: 'missing_or_unknown_tenant', - hint: 'Add ?tenant=agua-miia (slug) to the webhook URL', + hint: 'Add ?tenant=&secret=… to the webhook URL', }, { status: 400 } ); @@ -260,10 +353,12 @@ export async function POST(req: NextRequest) { try { if (entity === 'contact') { const r = await upsertLead(tenantId, payload); + await markClientifyConnected(tenantId, tenantParam); return NextResponse.json({ ok: true, entity, ...r }); } if (entity === 'deal') { const r = await upsertDeal(tenantId, payload); + await markClientifyConnected(tenantId, tenantParam); return NextResponse.json({ ok: true, entity, ...r }); } // company/activity/budget/product: acknowledge for now @@ -279,3 +374,52 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: msg }, { status: 500 }); } } + +/** Webhook escribe leads pero la ficha quedaba "disconnected" — alinear status. */ +async function markClientifyConnected( + tenantId: string, + slugHint: string | null +) { + const headers = serviceHeaders(); + if (!headers) return; + const base = supabaseUrl(); + const now = new Date().toISOString(); + const body = { + status: 'connected', + enabled: true, + last_sync_at: now, + error_message: null, + label: 'Clientify', + external_account_id: slugHint || undefined, + meta: { + source: 'webhook', + synced_at: now, + }, + }; + const patch = await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.clientify`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(body), + } + ); + if (patch.ok) return; + await fetch(`${base}/rest/v1/integrations`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'resolution=ignore-duplicates,return=minimal', + }, + body: JSON.stringify({ + tenant_id: tenantId, + provider: 'clientify', + ...body, + }), + }); +} diff --git a/src/app/dashboard/crm/page.tsx b/src/app/dashboard/crm/page.tsx index 59be813..b28b59e 100644 --- a/src/app/dashboard/crm/page.tsx +++ b/src/app/dashboard/crm/page.tsx @@ -15,6 +15,9 @@ async function fetchCrmLeads( includeTenantId: boolean ): Promise { const select = includeTenantId + ? 'tenant_id,source,created_at,status,updated_at,status_changed_at' + : 'source,created_at,status,updated_at,status_changed_at'; + const selectFallback = includeTenantId ? 'tenant_id,source,created_at,status' : 'source,created_at,status'; const pageSize = 1000; @@ -27,12 +30,20 @@ async function fetchCrmLeads( const tidFilter = tenantId ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` : ''; + let useSelect = select; for (let offset = 0; offset < 80000; offset += pageSize) { const r = await fetch( - `${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`, + `${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`, { headers, cache: 'no-store' } ); - if (!r.ok) break; + if (!r.ok) { + if (useSelect !== selectFallback && offset === 0) { + useSelect = selectFallback; + offset = -pageSize; + continue; + } + break; + } const chunk = (await r.json()) as CrmLeadRow[]; out.push(...chunk); if (chunk.length < pageSize) break; @@ -42,12 +53,24 @@ async function fetchCrmLeads( const supabase = await createClient(); for (let offset = 0; offset < 80000; offset += pageSize) { - const { data } = await supabase + let q = supabase .from('leads') .select(select) .eq('crm', 'clientify') .order('created_at', { ascending: false }) .range(offset, offset + pageSize - 1); + let { data, error } = await q; + if (error && offset === 0) { + const retry = await supabase + .from('leads') + .select(selectFallback) + .eq('crm', 'clientify') + .order('created_at', { ascending: false }) + .range(offset, offset + pageSize - 1); + data = retry.data; + error = retry.error; + } + if (error) break; const chunk = (data as CrmLeadRow[]) || []; out.push(...chunk); if (chunk.length < pageSize) break; diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index d609994..0e3412b 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -12,6 +12,7 @@ import { buildComparisons, mergeDealsIntoWeekly, resolveWeeklyRows, + resolveWonCohort, sumDaily, sumWeekly, } from '@/lib/metrics'; @@ -19,12 +20,13 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc export const dynamic = 'force-dynamic'; -/** Paginate CRM leads (source + created_at) for attribution cross. */ +/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */ async function fetchCrmLeads( tenantId: string | null, useServiceRole: boolean ): Promise { - const select = 'source,created_at'; + const select = 'source,created_at,status,tenant_id,updated_at,status_changed_at'; + const selectFallback = 'source,created_at,status,tenant_id'; const pageSize = 1000; const out: CrmLeadRow[] = []; @@ -35,12 +37,20 @@ async function fetchCrmLeads( const tidFilter = tenantId ? `&tenant_id=eq.${encodeURIComponent(tenantId)}` : ''; + let useSelect = select; for (let offset = 0; offset < 50000; offset += pageSize) { const r = await fetch( - `${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`, + `${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`, { headers, cache: 'no-store' } ); - if (!r.ok) break; + if (!r.ok) { + if (useSelect !== selectFallback && offset === 0) { + useSelect = selectFallback; + offset = -pageSize; + continue; + } + break; + } const chunk = (await r.json()) as CrmLeadRow[]; out.push(...chunk); if (chunk.length < pageSize) break; @@ -50,12 +60,23 @@ async function fetchCrmLeads( const supabase = await createClient(); for (let offset = 0; offset < 50000; offset += pageSize) { - const { data } = await supabase + let { data, error } = await supabase .from('leads') .select(select) .eq('crm', 'clientify') .order('created_at', { ascending: false }) .range(offset, offset + pageSize - 1); + if (error && offset === 0) { + const retry = await supabase + .from('leads') + .select(selectFallback) + .eq('crm', 'clientify') + .order('created_at', { ascending: false }) + .range(offset, offset + pageSize - 1); + data = retry.data; + error = retry.error; + } + if (error) break; const chunk = (data as CrmLeadRow[]) || []; out.push(...chunk); if (chunk.length < pageSize) break; @@ -163,6 +184,9 @@ export default async function KpisPage() { wonDeals = await fetchWonDeals(ctx.tenantId, false); } + const cohort = resolveWonCohort(wonDeals, crmLeads); + wonDeals = cohort.deals; + weekly = mergeDealsIntoWeekly(resolveWeeklyRows(weekly, daily), wonDeals); const comparisons = buildComparisons(weekly); @@ -188,6 +212,7 @@ export default async function KpisPage() { dailyRows={daily} crmLeads={crmLeads} wonDeals={wonDeals} + cohortSource={cohort.source} /> ); } diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx index 0dcaae1..0c5626d 100644 --- a/src/components/ClientIntegrationsPanel.tsx +++ b/src/components/ClientIntegrationsPanel.tsx @@ -16,7 +16,7 @@ type Integ = { meta?: { folder_name?: string; match_score?: number } | null; }; -type Provider = { id: string; label: string; group: string }; +type Provider = { id: string; label: string; group: string; live?: boolean }; const PLACEHOLDERS: Record = { meta_ads: 'act_… (Ad Account ID)', @@ -219,6 +219,11 @@ export default function ClientIntegrationsPanel({

{p.label} + {p.live === false && ( + + (sin conector API aún) + + )}

{folderHint && (

diff --git a/src/components/CreativesPipeline.tsx b/src/components/CreativesPipeline.tsx index 2a0712b..d1dbff8 100644 --- a/src/components/CreativesPipeline.tsx +++ b/src/components/CreativesPipeline.tsx @@ -299,7 +299,7 @@ export default function CreativesPipeline() { setDraft((d) => ({ ...d, brand_name: e.target.value })) } className={inputCls} - placeholder="Agua Miia" + placeholder="Marca del cliente" /> diff --git a/src/components/CrmDashboard.tsx b/src/components/CrmDashboard.tsx index 84f04eb..15a2c6f 100644 --- a/src/components/CrmDashboard.tsx +++ b/src/components/CrmDashboard.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Users, UserCheck, @@ -12,6 +12,7 @@ import { import { AdDailyRow, CrmChannelStats, + CrmDateLens, CrmLeadRow, attributionCross, buildCrmChannelStats, @@ -32,6 +33,15 @@ function rateClass(rate: number): string { return 'text-gray-500'; } +const LENS_HELP: Record = { + created: + 'Altas: leads cuya fecha de creación en Clientify cae en el periodo del header.', + status: + 'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at (sql/leads_status_timing.sql).', + snapshot: + 'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora en el warehouse.', +}; + export default function CrmDashboard({ label, portfolioWide, @@ -44,11 +54,19 @@ export default function CrmDashboard({ dailyRows?: AdDailyRow[]; }) { const { since, until, label: rangeHint } = useDateRange(); + const [lens, setLens] = useState('created'); + + const hasStatusTiming = useMemo( + () => crmLeads.some((l) => Boolean(l.status_changed_at || l.updated_at)), + [crmLeads] + ); const rangedLeads = useMemo(() => { if (!crmLeads.length) return []; - return filterLeadsByDayRange(crmLeads, since, until); - }, [crmLeads, since, until]); + const effective: CrmDateLens = + lens === 'status' && !hasStatusTiming ? 'created' : lens; + return filterLeadsByDayRange(crmLeads, since, until, effective); + }, [crmLeads, since, until, lens, hasStatusTiming]); const rangedDaily = useMemo(() => { if (!dailyRows.length) return []; @@ -90,6 +108,43 @@ export default function CrmDashboard({ description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`} /> +

+

+ Lente CRM +

+
+ {( + [ + ['created', 'Altas en periodo'], + ['status', 'Conversiones en periodo'], + ['snapshot', 'Estado actual'], + ] as const + ).map(([id, lab]) => ( + + ))} +
+
+

{LENS_HELP[lens]}

+ {lens === 'status' && !hasStatusTiming ? ( +

+ Aún no hay status_changed_at en + warehouse — aplicando filtro por alta. Ejecuta{' '} + sql/leads_status_timing.sql en + Supabase y deja que los webhooks actualicen contactos. +

+ ) : null} + {!crmLeads.length ? (

Sin leads Clientify en este contexto. Importa el histórico o activa el @@ -97,7 +152,6 @@ export default function CrmDashboard({

) : null} - {/* North star */}
{[ { @@ -118,12 +172,12 @@ export default function CrmDashboard({ { label: 'En oportunidad', value: formatNum(funnel.enOportunidad), - icon: , + icon: , }, { - label: 'Lead frío*', + label: 'Lead frío', value: formatNum(funnel.leadFrio), - icon: , + icon: , }, { label: 'Lead perdido', @@ -133,65 +187,96 @@ export default function CrmDashboard({ ].map((s) => (
-
-

{s.label}

+
+ + {s.label} + {s.icon}
-

{s.value}

+

+ {s.value} +

))}
-

- *Frío incluye templado / caliente / cold-lead. -

- {/* % cliente por canal — paid first */} -
+

% a cliente por canal

-

+

Estado Clientify = cliente{' '} - (ganado). Paid arriba; resto de atribución abajo. + (ganado). Ads spend del periodo es referencia.

- - {channelRows.length === 0 ? ( -

- Sin canales en el periodo. + {!channelRows.length ? ( +

+ Sin leads en esta lente/periodo.

) : ( -
- - +
+ + + + + + + + + + + + {[...paidChannels, ...otherChannels].map((row) => ( + + + + + + + + ))} + +
CanalLeadsClientes% a clienteInversión ads
+ {row.channel} + + {formatNum(row.leads)} + + {formatNum(row.cliente)} + + {row.clienteRate.toFixed(1)}% + + {spendByChannel.get(row.channel) + ? formatEur(spendByChannel.get(row.channel)!) + : '—'} +
)} +

+ La inversión por canal (si hay ads) es solo referencia; el % a cliente + es CRM. Lente activa: {lens}. +

- {/* Funnel statuses */} -
-

Funnel por estado

- {statusRows.length === 0 ? ( -

Sin datos.

+
+

Desglose de status

+ {!statusRows.length ? ( +

Sin datos.

) : (
- - +
+ - - - + + + @@ -200,13 +285,11 @@ export default function CrmDashboard({ key={row.status} className="border-b border-gray-100 hover:bg-gray-50" > - - + - @@ -216,91 +299,6 @@ export default function CrmDashboard({ )} - -

- La inversión por canal (si hay ads) es solo referencia; el % a cliente es - 100% CRM (Clientify). -

- - ); -} - -function ChannelTable({ - title, - rows, - spendByChannel, -}: { - title: string; - rows: CrmChannelStats[]; - spendByChannel: Map; -}) { - if (!rows.length) { - return ( -
-

- {title} -

-

Sin filas.

-
- ); - } - return ( -
-

- {title} -

-
EstadoLeads% del totalStatusN%
- {row.status} - + {row.status} {formatNum(row.count)} + {row.pct.toFixed(1)}%
- - - - - - - - - - - - - - {rows.map((row) => { - const spend = spendByChannel.get(row.channel) || 0; - return ( - - - - - - - - - - - ); - })} - -
CanalLeadsClientes% a clienteEn oportunidadFrío*PerdidoSpend ads
- {row.channel} - - {formatNum(row.leads)} - - {formatNum(row.cliente)} - - {row.clienteRate.toFixed(1)}% - - {formatNum(row.enOportunidad)} - - {formatNum(row.leadFrio)} - - {formatNum(row.leadPerdido)} - - {spend ? formatEur(spend) : '—'} -
); } diff --git a/src/components/DateRangeContext.tsx b/src/components/DateRangeContext.tsx index b28aab0..aa08813 100644 --- a/src/components/DateRangeContext.tsx +++ b/src/components/DateRangeContext.tsx @@ -14,7 +14,7 @@ import { isoYesterday, } from '@/lib/ad-tracker-range'; -const STORAGE_KEY = '4v_global_date_range_v1'; +const STORAGE_KEY = '4v_global_date_range_v2'; type Stored = { preset: RangePreset; @@ -38,24 +38,24 @@ const DateRangeContext = createContext(null); function loadStored(): Stored { const y = isoYesterday(); if (typeof window === 'undefined') { - return { preset: 'last_7d', customSince: y, customUntil: y }; + return { preset: 'last_30d', customSince: y, customUntil: y }; } try { const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return { preset: 'last_7d', customSince: y, customUntil: y }; + if (!raw) return { preset: 'last_30d', customSince: y, customUntil: y }; const j = JSON.parse(raw) as Stored; return { - preset: j.preset || 'last_7d', + preset: j.preset || 'last_30d', customSince: j.customSince || y, customUntil: j.customUntil || y, }; } catch { - return { preset: 'last_7d', customSince: y, customUntil: y }; + return { preset: 'last_30d', customSince: y, customUntil: y }; } } export function DateRangeProvider({ children }: { children: React.ReactNode }) { - const [preset, setPresetState] = useState('last_7d'); + const [preset, setPresetState] = useState('last_30d'); const [customSince, setCustomSince] = useState(''); const [customUntil, setCustomUntil] = useState(''); const [hydrated, setHydrated] = useState(false); diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index 5376bb1..b3e4ee1 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -24,6 +24,7 @@ import { formatNum, formatPct, attributionCross, + buildRangeDeltas, filterDailyByDayRange, filterDealsForRange, filterLeadsByDayRange, @@ -50,22 +51,31 @@ function DeltaTriple({ delta, invert, empty, + format, + current, }: { delta: Delta; invert?: boolean; empty?: boolean; + format: (v: number) => string; + current: number; }) { if (empty) { return (

- Sin cierres en el periodo — sincroniza deals Clientify + Sin cierres en este rango — amplía el periodo o revisa deals ganados en + Clientify

); } - const cells: { label: string; v: number | null }[] = [ - { label: 'WoW', v: delta.wow }, - { label: 'MoM', v: delta.mom }, - { label: 'YoY', v: delta.yoy }, + const cells: { + label: string; + v: number | null; + base: number | null; + }[] = [ + { label: 'WoW', v: delta.wow, base: delta.wowBase }, + { label: 'MoM', v: delta.mom, base: delta.momBase }, + { label: 'YoY', v: delta.yoy, base: delta.yoyBase }, ]; return (
@@ -85,6 +95,9 @@ function DeltaTriple({ : good ? ArrowUpRight : ArrowDownRight; + const hasBase = c.base !== null && !(c.base === 0 && c.v === null); + const absDelta = + hasBase && c.base !== null ? current - c.base : null; return (
{formatPct(c.v)}

+ {hasBase ? ( + <> +

+ prev {format(c.base ?? 0)} +

+ {absDelta !== null && ( +

+ Δ{' '} + {absDelta > 0 + ? `+${format(absDelta)}` + : format(absDelta)} +

+ )} + + ) : ( +

+ sin histórico +

+ )}
); })} @@ -127,6 +161,7 @@ export default function MetricsDashboard({ dailyRows = [], crmLeads = [], wonDeals = [], + cohortSource = 'deals', }: { label: string; portfolioWide: boolean; @@ -145,6 +180,7 @@ export default function MetricsDashboard({ dailyRows?: AdDailyRow[]; crmLeads?: CrmLeadRow[]; wonDeals?: DealWonRow[]; + cohortSource?: 'deals' | 'cliente_leads' | 'none'; }) { const { since, until, label: rangeHint } = useDateRange(); @@ -178,9 +214,15 @@ export default function MetricsDashboard({ return withCohortFromDeals(base, rangedDeals); }, [rangedDaily, rangedDeals, weeklyRows, since, until, month]); + /** Comparatives relative to the selected range (not “today”). */ + const rangeDeltas = useMemo( + () => buildRangeDeltas(dailyRows, wonDeals, since, until), + [dailyRows, wonDeals, since, until] + ); + const attributionRows: AttributionChannelRow[] = useMemo(() => { - if (rangedDaily.length || rangedLeads.length) { - return attributionCross(rangedDaily, rangedLeads); + if (rangedDaily.length || rangedLeads.length || rangedDeals.length) { + return attributionCross(rangedDaily, rangedLeads, rangedDeals); } const map = new Map(); for (const p of channelPoints) { @@ -200,14 +242,42 @@ export default function MetricsDashboard({ gap: -v.leads, cplPlatform: v.leads > 0 ? v.spend / v.leads : 0, cplCrm: 0, + cierres: 0, + ventas: 0, + roas: 0, + cac: 0, })) .sort((a, b) => b.spend - a.spend); - }, [rangedDaily, rangedLeads, channelPoints, since, until]); + }, [rangedDaily, rangedLeads, rangedDeals, channelPoints, since, until]); + + const attributionTotals = useMemo(() => { + return attributionRows.reduce( + (a, r) => ({ + spend: a.spend + r.spend, + platformLeads: a.platformLeads + r.platformLeads, + crmLeads: a.crmLeads + r.crmLeads, + gap: a.gap + r.gap, + cierres: a.cierres + r.cierres, + ventas: a.ventas + r.ventas, + }), + { + spend: 0, + platformLeads: 0, + crmLeads: 0, + gap: 0, + cierres: 0, + ventas: 0, + } + ); + }, [attributionRows]); const crmPeriodTotal = rangedLeads.length; const hasCrm = crmLeads.length > 0; const hasCohort = rangedDeals.length > 0; const cierres = periodTotals.deals; + const cohortFromLeads = cohortSource === 'cliente_leads'; + const revenueMissing = + hasCohort && periodTotals.revenue <= 0 && cohortFromLeads; const cards: Card[] = [ { @@ -236,8 +306,10 @@ export default function MetricsDashboard({ key: 'revenue', label: 'Ventas (CRM)', hint: hasCohort - ? `${formatNum(cierres)} deals ganados · importe Clientify` - : 'Deals won Clientify en el periodo', + ? cohortFromLeads + ? `${formatNum(cierres)} clientes Clientify · importe no en webhook` + : `${formatNum(cierres)} deals ganados · importe Clientify` + : 'Deals won / status cliente Clientify en el periodo', format: formatEur, icon: , cohort: true, @@ -246,7 +318,7 @@ export default function MetricsDashboard({ key: 'cac', label: 'CAC', hint: hasCohort - ? `Inversión ads ÷ ${formatNum(cierres)} ventas` + ? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}` : 'Inversión ÷ deals ganados (no leads)', format: (v) => `${formatNum(v)} €`, icon: , @@ -256,7 +328,9 @@ export default function MetricsDashboard({ { key: 'roas', label: 'ROAS ventas', - hint: 'Ventas CRM ÷ inversión ads', + hint: revenueMissing + ? 'Falta importe en CRM (API BPS) para ROAS' + : `Importe deals ganados Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`, format: (v) => `${formatNum(v)}x`, icon: , cohort: true, @@ -287,7 +361,11 @@ export default function MetricsDashboard({ const worstGap = [...attributionRows].sort( (a, b) => Math.abs(b.gap) - Math.abs(a.gap) )[0]; - return { top, worstGap }; + const platTotal = attributionRows.reduce((a, r) => a + r.platformLeads, 0); + const crmTotal = attributionRows.reduce((a, r) => a + r.crmLeads, 0); + const crmPaid = paid.reduce((a, r) => a + r.crmLeads, 0); + const crmOther = crmTotal - crmPaid; + return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther }; }, [attributionRows]); return ( @@ -307,8 +385,20 @@ export default function MetricsDashboard({ {!hasCohort && (

- Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con - fecha de cierre en este periodo. El gasto de ads no cuenta como venta. + Ventas / CAC / ROAS vacíos en {rangeHint}: no hay + deals ganados ni contactos con status «cliente» en este rango + {wonDeals.length + ? ` (sí hay ${formatNum(wonDeals.length)} en el histórico — prueba «30 días» o «Mes»).` + : '.'}{' '} + El gasto de ads no cuenta como venta. +

+ )} + {cohortFromLeads && hasCohort && ( +

+ Cohorte desde contactos Clientify status «cliente» (no hay deals + ganados sincronizados). Importe € incompleto hasta conectar la API + Clientify de este tenant. Meta pixel reporta compras en el periodo; + el CRM marca clientes sin amount en el webhook.

)} @@ -350,7 +440,13 @@ export default function MetricsDashboard({
{cards.map((c) => { const emptyCohort = Boolean(c.cohort) && !hasCohort; - const value = emptyCohort ? '—' : c.format(periodTotals[c.key]); + const showClientesCount = + c.key === 'revenue' && revenueMissing && !emptyCohort; + const value = emptyCohort + ? '—' + : showClientesCount + ? `${formatNum(cierres)} clientes` + : c.format(periodTotals[c.key]); return (

{c.hint}

); @@ -383,16 +481,9 @@ export default function MetricsDashboard({ Cruce por canal · ads vs CRM

- Gasto y leads de plataforma vs leads CRM (como en{' '} - - combinación de datos - - ): el coste vive en ads; el resultado de negocio en CRM/GA. + Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las + ventas de deals sin fuente de contacto aparecen en{' '} + sin-atribuir.

@@ -401,16 +492,17 @@ export default function MetricsDashboard({

{channelInsight && (

- Resumen: más inversión en{' '} - - {channelInsight.top.channel} - {' '} - ({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '} + Resumen: plat {formatNum(channelInsight.platTotal)}{' '} + vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '} + {channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''} + {formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De + los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto + ads y {formatNum(channelInsight.crmOther)} en otras fuentes + (integrations, ecommerce, messenger…). Mayor desfase en{' '} {channelInsight.worstGap.channel} - {' '} - (gap {channelInsight.worstGap.gap > 0 ? '+' : ''} - {formatNum(channelInsight.worstGap.gap)}). + + .

)} {attributionRows.length === 0 ? ( @@ -427,6 +519,9 @@ export default function MetricsDashboard({ Leads plat Leads CRM Gap + Cierres + Ventas + ROAS CPL plat CPL CRM @@ -469,6 +564,15 @@ export default function MetricsDashboard({ ? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}` : '—'} + + {hasCrm ? formatNum(row.cierres) : '—'} + + + {row.ventas > 0 ? formatEur(row.ventas) : '—'} + + + {row.roas > 0 ? `${formatNum(row.roas)}x` : '—'} + {row.cplPlatform ? `${formatNum(row.cplPlatform)} €` @@ -482,6 +586,70 @@ export default function MetricsDashboard({ ))} + {attributionRows.length > 0 && ( + + + Total + + {attributionTotals.spend + ? formatEur(attributionTotals.spend) + : '—'} + + + {formatNum(attributionTotals.platformLeads)} + + + {hasCrm ? formatNum(attributionTotals.crmLeads) : '—'} + + + {hasCrm + ? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}` + : '—'} + + + {hasCrm ? formatNum(attributionTotals.cierres) : '—'} + + + {attributionTotals.ventas > 0 || periodTotals.revenue > 0 + ? formatEur( + attributionTotals.ventas || periodTotals.revenue + ) + : '—'} + + + {attributionTotals.spend > 0 && + (attributionTotals.ventas || periodTotals.revenue) > 0 + ? `${formatNum( + (attributionTotals.ventas || + periodTotals.revenue) / attributionTotals.spend + )}x` + : '—'} + + + {attributionTotals.platformLeads > 0 + ? `${formatNum( + attributionTotals.spend / + attributionTotals.platformLeads + )} €` + : '—'} + + + {hasCrm && attributionTotals.crmLeads > 0 + ? `${formatNum( + attributionTotals.spend / + attributionTotals.crmLeads + )} €` + : '—'} + + + + )}
)} @@ -498,13 +666,13 @@ export default function MetricsDashboard({ {dailyChart.map((d) => (
@@ -523,13 +691,18 @@ export default function MetricsDashboard({ {dailyChart.map((d) => (
x.leads))) * 100)}%`, + height: `${Math.max( + 4, + (d.leads / + Math.max(1, ...dailyChart.map((x) => x.leads))) * + 80 + )}px`, }} />
diff --git a/src/lib/ads-discovery.ts b/src/lib/ads-discovery.ts index d6cdb30..d6d4ab3 100644 --- a/src/lib/ads-discovery.ts +++ b/src/lib/ads-discovery.ts @@ -8,7 +8,7 @@ import { } from '@/lib/match'; const META_GRAPH = 'https://graph.facebook.com/v21.0'; -const GADS_API = 'https://googleads.googleapis.com/v18'; +const GADS_API = 'https://googleads.googleapis.com/v22'; export const META_OAUTH_PROVIDER = 'meta_ads'; export function metaEnvConfigured(): boolean { diff --git a/src/lib/ads-sync.ts b/src/lib/ads-sync.ts index 6841397..c66ecbd 100644 --- a/src/lib/ads-sync.ts +++ b/src/lib/ads-sync.ts @@ -4,7 +4,7 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { resolveMetaAccessToken } from '@/lib/ads-discovery'; const META_GRAPH = 'https://graph.facebook.com/v21.0'; -const GADS_API = 'https://googleads.googleapis.com/v21'; +const GADS_API = 'https://googleads.googleapis.com/v22'; export type SyncTenantResult = { tenantId: string; @@ -20,8 +20,25 @@ type IntegrationRow = { provider: string; external_account_id: string | null; status?: string; + meta?: { + extra_ad_accounts?: { account_id?: string; external_account_id?: string }[]; + alternate_accounts?: { external_account_id?: string }[]; + } | null; }; +function extraAccountIds(row: IntegrationRow | undefined): string[] { + if (!row) return []; + const out: string[] = []; + for (const x of row.meta?.extra_ad_accounts || []) { + const id = x.account_id || x.external_account_id; + if (id) out.push(id); + } + for (const x of row.meta?.alternate_accounts || []) { + if (x.external_account_id) out.push(x.external_account_id); + } + return out; +} + type TenantRow = { id: string; name: string }; type AdDailyInsert = { @@ -114,22 +131,39 @@ function gadsHeaders(accessToken: string, loginCustomerId?: string) { return h; } -async function fetchMetaAdDaily( - accountId: string, - tenantId: string, - days: number -): Promise { - const token = await resolveMetaAccessToken(); - if (!token) throw new Error('Sin token Meta'); - const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`; - const since = new Date(); - since.setUTCDate(since.getUTCDate() - days); - const until = new Date(); - const timeRange = JSON.stringify({ - since: since.toISOString().slice(0, 10), - until: until.toISOString().slice(0, 10), - }); +function isoDayUTC(d: Date): string { + return d.toISOString().slice(0, 10); +} +/** Inclusive day windows of at most `chunkDays` from `since` → today. */ +function dayWindows(days: number, chunkDays: number): { since: string; until: string }[] { + const until = new Date(); + const start = new Date(); + start.setUTCDate(start.getUTCDate() - days); + start.setUTCHours(0, 0, 0, 0); + until.setUTCHours(0, 0, 0, 0); + + const out: { since: string; until: string }[] = []; + let cursor = new Date(start); + while (cursor <= until) { + const end = new Date(cursor); + end.setUTCDate(end.getUTCDate() + chunkDays - 1); + if (end > until) end.setTime(until.getTime()); + out.push({ since: isoDayUTC(cursor), until: isoDayUTC(end) }); + cursor = new Date(end); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return out; +} + +async function fetchMetaInsightsWindow( + act: string, + token: string, + tenantId: string, + since: string, + until: string +): Promise { + const timeRange = JSON.stringify({ since, until }); const out: AdDailyInsert[] = []; let url: string | null = `${META_GRAPH}/${act}/insights` + @@ -177,19 +211,32 @@ async function fetchMetaAdDaily( return out; } -async function fetchGoogleCampaignDaily( - customerId: string, +async function fetchMetaAdDaily( + accountId: string, tenantId: string, - days: number, + days: number +): Promise { + const token = await resolveMetaAccessToken(); + if (!token) throw new Error('Sin token Meta'); + const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`; + // Meta rejects huge ad-level ranges — pull in ~30d windows + const windows = dayWindows(days, 30); + const out: AdDailyInsert[] = []; + for (const w of windows) { + out.push( + ...(await fetchMetaInsightsWindow(act, token, tenantId, w.since, w.until)) + ); + } + return out; +} + +async function fetchGoogleWindow( + cid: string, + tenantId: string, + sinceStr: string, + untilStr: string, accessToken: string ): Promise { - const cid = customerId.replace(/-/g, ''); - const since = new Date(); - since.setUTCDate(since.getUTCDate() - days); - const until = new Date(); - const sinceStr = since.toISOString().slice(0, 10); - const untilStr = until.toISOString().slice(0, 10); - const query = ` SELECT campaign.id, @@ -304,6 +351,23 @@ async function fetchGoogleCampaignDaily( return out; } +async function fetchGoogleCampaignDaily( + customerId: string, + tenantId: string, + days: number, + accessToken: string +): Promise { + const cid = customerId.replace(/-/g, ''); + const windows = dayWindows(days, 90); + const out: AdDailyInsert[] = []; + for (const w of windows) { + out.push( + ...(await fetchGoogleWindow(cid, tenantId, w.since, w.until, accessToken)) + ); + } + return out; +} + async function replaceDaily( tenantId: string, platforms: string[], @@ -350,17 +414,23 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) { since.setUTCDate(since.getUTCDate() - days); const sinceStr = since.toISOString().slice(0, 10); - const r = await fetch( - `${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads`, - { headers, cache: 'no-store' } - ); - if (!r.ok) throw new Error(await r.text()); - const daily = (await r.json()) as { + const daily: { date?: string; platform?: string; spend?: number; platform_leads?: number; - }[]; + }[] = []; + const pageSize = 1000; + for (let offset = 0; offset < 200000; offset += pageSize) { + const r = await fetch( + `${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads&order=date.asc&limit=${pageSize}&offset=${offset}`, + { headers, cache: 'no-store' } + ); + if (!r.ok) throw new Error(await r.text()); + const chunk = (await r.json()) as typeof daily; + daily.push(...chunk); + if (chunk.length < pageSize) break; + } type Agg = { spend: number; leads: number }; const map = new Map(); @@ -401,27 +471,33 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) { if (payload.length) { // kpi_weekly may be a materialized view — best-effort only - const ins = await fetch(`${base}/rest/v1/kpi_weekly`, { - method: 'POST', - headers: { - ...headers, - 'Content-Type': 'application/json', - Prefer: 'resolution=merge-duplicates,return=minimal', - }, - body: JSON.stringify(payload), - }); - if (!ins.ok) { - const msg = await ins.text(); - if (msg.includes('materialized view')) { - // UI falls back to ad_daily; skip hard failure - return 0; + for (let i = 0; i < payload.length; i += 400) { + const chunk = payload.slice(i, i + 400); + const ins = await fetch(`${base}/rest/v1/kpi_weekly`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'resolution=merge-duplicates,return=minimal', + }, + body: JSON.stringify(chunk), + }); + if (!ins.ok) { + const msg = await ins.text(); + if (msg.includes('materialized view')) { + // UI falls back to ad_daily; skip hard failure + return 0; + } + throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`); } - throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`); } } return payload.length; } +/** Max lookback for intentional backfills (≈ Jan 2024 → now). Cron stays short. */ +export const ADS_BACKFILL_MAX_DAYS = 1000; + export async function syncAdsForTenants(opts?: { tenantId?: string; days?: number; @@ -429,20 +505,29 @@ export async function syncAdsForTenants(opts?: { const headers = serviceHeaders(); if (!headers) throw new Error('no service role'); const base = supabaseUrl(); - const days = opts?.days ?? 30; + const days = Math.min( + ADS_BACKFILL_MAX_DAYS, + Math.max(1, opts?.days ?? 30) + ); const tRes = await fetch( - `${base}/rest/v1/tenants?select=id,name&order=name.asc`, + `${base}/rest/v1/tenants?select=id,name&agency_followed=neq.false&order=name.asc`, { headers, cache: 'no-store' } ); if (!tRes.ok) throw new Error(await tRes.text()); let tenants = (await tRes.json()) as TenantRow[]; if (opts?.tenantId) { - tenants = tenants.filter((t) => t.id === opts.tenantId); + // Explicit id may be archived satellite — still allow + const all = await fetch( + `${base}/rest/v1/tenants?select=id,name&order=name.asc`, + { headers, cache: 'no-store' } + ); + const allTenants = all.ok ? ((await all.json()) as TenantRow[]) : tenants; + tenants = allTenants.filter((t) => t.id === opts.tenantId); } const iRes = await fetch( - `${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status&external_account_id=not.is.null`, + `${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status,meta&external_account_id=not.is.null`, { headers, cache: 'no-store' } ); if (!iRes.ok) throw new Error(await iRes.text()); @@ -471,52 +556,77 @@ export async function syncAdsForTenants(opts?: { const platforms: string[] = []; const meta = mine.find((i) => i.provider === 'meta_ads' && i.external_account_id); - if (meta?.external_account_id) { + const metaIds = [ + ...(meta?.external_account_id ? [meta.external_account_id] : []), + ...extraAccountIds(meta), + ]; + for (const accountId of [...new Set(metaIds)]) { try { - const mrows = await fetchMetaAdDaily( - meta.external_account_id, - tenant.id, - days - ); + const mrows = await fetchMetaAdDaily(accountId, tenant.id, days); rows.push(...mrows); - platforms.push('meta'); - result.metaRows = mrows.length; + result.metaRows += mrows.length; } catch (e) { result.errors.push( e instanceof Error ? e.message : 'meta sync error' ); } } + if (result.metaRows > 0) platforms.push('meta'); const gads = mine.find( (i) => i.provider === 'google_ads' && i.external_account_id ); - if (gads?.external_account_id) { + const googleIds = [ + ...(gads?.external_account_id ? [gads.external_account_id] : []), + ...extraAccountIds(gads), + ]; + if (googleIds.length) { if (!gToken) { result.errors.push('Google Ads sin token OAuth'); } else { - try { - const grows = await fetchGoogleCampaignDaily( - gads.external_account_id, - tenant.id, - days, - gToken - ); - rows.push(...grows); - platforms.push('google'); - result.googleRows = grows.length; - } catch (e) { - result.errors.push( - e instanceof Error ? e.message : 'google sync error' - ); + for (const customerId of [...new Set(googleIds)]) { + try { + const grows = await fetchGoogleCampaignDaily( + customerId, + tenant.id, + days, + gToken + ); + rows.push(...grows); + result.googleRows += grows.length; + } catch (e) { + result.errors.push( + e instanceof Error ? e.message : 'google sync error' + ); + } } } } + if (result.googleRows > 0) platforms.push('google'); if (rows.length && platforms.length) { try { await replaceDaily(tenant.id, platforms, rows, days); result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days); + const now = new Date().toISOString(); + for (const p of platforms) { + const provider = p === 'meta' ? 'meta_ads' : 'google_ads'; + await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${provider}`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + last_sync_at: now, + error_message: null, + }), + } + ); + } } catch (e) { result.errors.push( e instanceof Error ? e.message : 'warehouse write error' diff --git a/src/lib/agency.ts b/src/lib/agency.ts index 9b91466..34946f3 100644 --- a/src/lib/agency.ts +++ b/src/lib/agency.ts @@ -1,23 +1,23 @@ /** Agency portfolio helpers — providers, role gates, service-role REST. */ export const INTEGRATION_PROVIDERS = [ - { id: 'meta_ads', label: 'Meta Ads', group: 'paid' }, - { id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid' }, - { id: 'ga4', label: 'Google Analytics 4', group: 'analytics' }, - { id: 'search_console', label: 'Search Console', group: 'analytics' }, - { id: 'tag_manager', label: 'Tag Manager', group: 'analytics' }, - { id: 'clientify', label: 'Clientify', group: 'crm' }, - { id: 'klaviyo', label: 'Klaviyo', group: 'email' }, - { id: 'mailchimp', label: 'Mailchimp', group: 'email' }, - { id: 'google_drive', label: 'Google Drive', group: 'files' }, - { id: 'wordpress', label: 'WordPress', group: 'cms' }, - { id: 'prestashop', label: 'PrestaShop', group: 'cms' }, - { id: 'shopify', label: 'Shopify', group: 'cms' }, - { id: 'instagram', label: 'Instagram', group: 'social' }, - { 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' }, + { id: 'meta_ads', label: 'Meta Ads', group: 'paid', live: true }, + { id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid', live: true }, + { id: 'ga4', label: 'Google Analytics 4', group: 'analytics', live: true }, + { id: 'search_console', label: 'Search Console', group: 'analytics', live: true }, + { id: 'tag_manager', label: 'Tag Manager', group: 'analytics', live: false }, + { id: 'clientify', label: 'Clientify', group: 'crm', live: true }, + { id: 'klaviyo', label: 'Klaviyo', group: 'email', live: false }, + { id: 'mailchimp', label: 'Mailchimp', group: 'email', live: false }, + { id: 'google_drive', label: 'Google Drive', group: 'files', live: true }, + { id: 'wordpress', label: 'WordPress', group: 'cms', live: false }, + { id: 'prestashop', label: 'PrestaShop', group: 'cms', live: false }, + { id: 'shopify', label: 'Shopify', group: 'cms', live: false }, + { id: 'instagram', label: 'Instagram', group: 'social', live: true }, + { id: 'facebook', label: 'Facebook Page', group: 'social', live: true }, + { id: 'tiktok', label: 'TikTok', group: 'social', live: false }, + { id: 'linkedin', label: 'LinkedIn', group: 'social', live: false }, + { id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops', live: true }, ] as const; export type IntegrationProviderId = diff --git a/src/lib/cron-auth.ts b/src/lib/cron-auth.ts new file mode 100644 index 0000000..13e46cc --- /dev/null +++ b/src/lib/cron-auth.ts @@ -0,0 +1,26 @@ +/** Shared auth for /api/cron/* — Bearer $CRON_SECRET or ?secret= */ + +import { NextRequest, NextResponse } from 'next/server'; + +export function assertCronAuth( + req: NextRequest +): NextResponse | null { + const secret = process.env.CRON_SECRET?.trim(); + const auth = req.headers.get('authorization') || ''; + const q = req.nextUrl.searchParams.get('secret') || ''; + const token = auth.startsWith('Bearer ') ? auth.slice(7) : q; + + if (!secret) { + return NextResponse.json( + { + error: 'CRON_SECRET no configurado', + hint: 'Define CRON_SECRET en Dokploy y programa el cron diario.', + }, + { status: 503 } + ); + } + if (token !== secret) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + return null; +} diff --git a/src/lib/google-organic.ts b/src/lib/google-organic.ts index ea5ed5d..2eed968 100644 --- a/src/lib/google-organic.ts +++ b/src/lib/google-organic.ts @@ -215,8 +215,9 @@ export async function applyOrganicMatches( meta: { property_id: m.ga4.propertyId, measurement_id: m.ga4.measurementId, + matched_at: new Date().toISOString(), }, - last_sync_at: new Date().toISOString(), + // last_sync_at solo tras volcar organic_daily (no al emparejar) error_message: null, }); if (ok) ga4 += 1; @@ -231,8 +232,10 @@ export async function applyOrganicMatches( status: 'connected', external_account_id: m.gsc.siteUrl, label: m.gsc.siteUrl, - meta: { site_url: m.gsc.siteUrl }, - last_sync_at: new Date().toISOString(), + meta: { + site_url: m.gsc.siteUrl, + matched_at: new Date().toISOString(), + }, error_message: null, } ); @@ -686,19 +689,21 @@ export async function syncAllConnectedOrganic(opts?: { gscSiteUrl: cfg.gscSiteUrl, days, }); - // bump last_sync on integrations - await fetch( - `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`, - { - method: 'PATCH', - headers: { - ...headers, - 'Content-Type': 'application/json', - Prefer: 'return=minimal', - }, - body: JSON.stringify({ last_sync_at: new Date().toISOString() }), - } - ); + // Solo marcar last_sync_at si hubo filas (no mentir tras match vacío) + if (r.daysWritten > 0) { + await fetch( + `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ last_sync_at: new Date().toISOString() }), + } + ); + } results.push({ tenantId, ...r }); } catch (e) { results.push({ diff --git a/src/lib/match.ts b/src/lib/match.ts index 3166f50..53f04c6 100644 --- a/src/lib/match.ts +++ b/src/lib/match.ts @@ -22,9 +22,29 @@ export function scoreNameToTenant( if (f === n || f === s) return 100; if (n && (f.includes(n) || n.includes(f))) return 85; if (s && (f.includes(s) || s.includes(f))) return 80; - const ft = new Set(f.split(' ').filter((t) => t.length > 2)); + // Ignore generic tokens that cause false clinic/school matches + const WEAK = new Set([ + 'clinica', + 'clinic', + 'dental', + 'school', + 'app', + 'the', + 'and', + 'de', + 'la', + 'el', + 'los', + 'las', + 'del', + ]); + const ft = new Set( + f.split(' ').filter((t) => t.length > 2 && !WEAK.has(t)) + ); const nt = new Set( - [...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2) + [...n.split(' '), ...s.split(' ')].filter( + (t) => t.length > 2 && !WEAK.has(t) + ) ); if (!ft.size || !nt.size) return 0; let hit = 0; diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 9f8697a..218788e 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -41,6 +41,10 @@ export type Delta = { wow: number | null; mom: number | null; yoy: number | null; + /** Absolute value of the comparison baseline (previous period). */ + wowBase: number | null; + momBase: number | null; + yoyBase: number | null; }; function n(v: unknown): number { @@ -163,6 +167,9 @@ export function buildComparisons(rows: KpiWeeklyRow[]): { wow: pctChange(currentWeek[k], prevWeek[k]), mom: pctChange(currentMonth[k], prevMonth[k]), yoy: pctChange(currentMonth[k], currentMonthLastYear[k]), + wowBase: prevWeek[k], + momBase: prevMonth[k], + yoyBase: currentMonthLastYear[k], }; } @@ -176,6 +183,107 @@ export function buildComparisons(rows: KpiWeeklyRow[]): { }; } +function shiftIsoDays(iso: string, deltaDays: number): string { + const d = new Date(`${iso}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + deltaDays); + return isoDate(d); +} + +function inclusiveDaySpan(since: string, until: string): number { + const a = new Date(`${since}T00:00:00Z`).getTime(); + const b = new Date(`${until}T00:00:00Z`).getTime(); + return Math.max(1, Math.round((b - a) / 86400000) + 1); +} + +function shiftYear(iso: string, years: number): string { + const d = new Date(`${iso}T00:00:00Z`); + d.setUTCFullYear(d.getUTCFullYear() + years); + return isoDate(d); +} + +/** Totals for an inclusive day window from daily ads + won deals. */ +export function totalsForDayRange( + daily: AdDailyRow[], + deals: DealWonRow[], + since: string, + until: string +): MetricTotals { + const base = metricTotalsFromDaily(filterDailyByDayRange(daily, since, until)); + return withCohortFromDeals(base, filterDealsForRange(deals, since, until)); +} + +/** + * WoW / MoM / YoY relative to the selected date range (not “today”). + * - WoW: previous window of the same length + * - MoM: previous calendar month if the range is a full month; else prior equal-length window + * - YoY: same calendar dates one year earlier + */ +export function buildRangeDeltas( + daily: AdDailyRow[], + deals: DealWonRow[], + since: string, + until: string +): Record { + const span = inclusiveDaySpan(since, until); + const cur = totalsForDayRange(daily, deals, since, until); + + const wowUntil = shiftIsoDays(since, -1); + const wowSince = shiftIsoDays(wowUntil, -(span - 1)); + const wow = totalsForDayRange(daily, deals, wowSince, wowUntil); + + const sinceD = new Date(`${since}T00:00:00Z`); + const untilD = new Date(`${until}T00:00:00Z`); + const isFullMonth = + sinceD.getUTCDate() === 1 && + untilD.getUTCDate() === + new Date( + Date.UTC(untilD.getUTCFullYear(), untilD.getUTCMonth() + 1, 0) + ).getUTCDate() && + sinceD.getUTCMonth() === untilD.getUTCMonth() && + sinceD.getUTCFullYear() === untilD.getUTCFullYear(); + + let momSince: string; + let momUntil: string; + if (isFullMonth) { + const prevMonthEnd = shiftIsoDays(since, -1); + const prevMonthStart = new Date( + Date.UTC(sinceD.getUTCFullYear(), sinceD.getUTCMonth() - 1, 1) + ); + momSince = isoDate(prevMonthStart); + momUntil = prevMonthEnd; + } else { + momUntil = shiftIsoDays(since, -1); + momSince = shiftIsoDays(momUntil, -(span - 1)); + } + const mom = totalsForDayRange(daily, deals, momSince, momUntil); + + const yoySince = shiftYear(since, -1); + const yoyUntil = shiftYear(until, -1); + const yoy = totalsForDayRange(daily, deals, yoySince, yoyUntil); + + const keys: (keyof MetricTotals)[] = [ + 'spend', + 'leads', + 'deals', + 'revenue', + 'cac', + 'roas', + 'cpl', + ]; + const deltas = {} as Record; + for (const k of keys) { + deltas[k] = { + wow: pctChange(cur[k], wow[k]), + mom: pctChange(cur[k], mom[k]), + yoy: pctChange(cur[k], yoy[k]), + wowBase: wow[k], + momBase: mom[k], + yoyBase: yoy[k], + }; + } + return deltas; +} + export function formatEur(v: number): string { return `${v.toLocaleString('es-ES', { minimumFractionDigits: 0, @@ -409,6 +517,8 @@ export type DealWonRow = { won_at?: string | null; stage?: string | null; tenant_id?: string | null; + /** Optional source/channel when joined from lead or Clientify contact. */ + source?: string | null; }; export function filterDealsForRange( @@ -484,6 +594,9 @@ export function mergeDealsIntoWeekly( export type CrmLeadRow = { source?: string | null; created_at?: string | null; + /** Last status change (→ cliente). Prefer for “ventas en periodo”. */ + status_changed_at?: string | null; + updated_at?: string | null; status?: string | null; tenant_id?: string | null; }; @@ -523,6 +636,9 @@ export function normalizeLeadStatus(status?: string | null): string { const s = (status || '').toLowerCase().trim(); if (!s) return 'sin-estado'; if (s === 'cold-lead') return 'lead frío'; + if (s === 'client' || s === 'customer' || s === 'won' || s === 'ganado') { + return 'cliente'; + } return s; } @@ -530,6 +646,46 @@ export function isClienteStatus(status?: string | null): boolean { return normalizeLeadStatus(status) === 'cliente'; } +/** + * When Clientify won-deals are missing (webhook without deals / no API key), + * treat contacts marked as cliente as cohort closes for Ventas / CAC / ROAS. + * Amount stays 0 unless the lead carries one — revenue may be incomplete. + */ +export function clienteLeadsAsWonDeals(leads: CrmLeadRow[]): DealWonRow[] { + const out: DealWonRow[] = []; + for (const l of leads) { + if (!isClienteStatus(l.status)) continue; + const wonAt = ( + l.status_changed_at || + l.updated_at || + l.created_at || + '' + ).trim(); + if (!wonAt) continue; + out.push({ + stage: 'won', + amount: 0, + won_at: wonAt, + tenant_id: l.tenant_id || null, + }); + } + return out; +} + +/** + * Prefer explicit won deals; if none, fall back to Clientify «cliente» leads. + */ +export function resolveWonCohort( + deals: DealWonRow[], + leads: CrmLeadRow[] +): { deals: DealWonRow[]; source: 'deals' | 'cliente_leads' | 'none' } { + const won = deals.filter((d) => !d.stage || d.stage === 'won'); + if (won.length) return { deals: won, source: 'deals' }; + const fromLeads = clienteLeadsAsWonDeals(leads); + if (fromLeads.length) return { deals: fromLeads, source: 'cliente_leads' }; + return { deals: [], source: 'none' }; +} + export type CrmFunnelTotals = { total: number; cliente: number; @@ -647,13 +803,27 @@ export function buildCrmChannelStats(leads: CrmLeadRow[]): CrmChannelStats[] { .sort((a, b) => b.cliente - a.cliente || b.leads - a.leads); } +export type CrmDateLens = 'created' | 'status' | 'snapshot'; + +/** + * Filter CRM leads by date. + * - created: alta del lead (default histórico) + * - status: cuándo cambió el status (ventas/clientes en el periodo) — needs status_changed_at + * - snapshot: ignore dates (estado actual del CRM) + */ export function filterLeadsByDayRange( rows: CrmLeadRow[], since: string, - until: string + until: string, + lens: CrmDateLens = 'created' ): CrmLeadRow[] { + if (lens === 'snapshot') return rows; return rows.filter((r) => { - const d = (r.created_at || '').slice(0, 10); + const raw = + lens === 'status' + ? r.status_changed_at || r.updated_at || r.created_at + : r.created_at; + const d = (raw || '').slice(0, 10); return d >= since && d <= until; }); } @@ -666,42 +836,72 @@ export type AttributionChannelRow = { gap: number; cplPlatform: number; cplCrm: number; + /** Contacts marked cliente (by CRM source) in the period. */ + cierres: number; + /** Won-deal revenue attributed to channel (or sin-atribuir). */ + ventas: number; + /** Ventas ÷ inversión (0 if no spend). */ + roas: number; + /** Inversión ÷ cierres (0 if no cierres). */ + cac: number; }; /** * Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify). * Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI). + * Ventas come from won deals; without deal.source they land in `sin-atribuir`. */ export function attributionCross( daily: AdDailyRow[], - leads: CrmLeadRow[] + leads: CrmLeadRow[], + deals: DealWonRow[] = [] ): AttributionChannelRow[] { const map = new Map< string, - { spend: number; platformLeads: number; crmLeads: number } + { + spend: number; + platformLeads: number; + crmLeads: number; + cierres: number; + ventas: number; + } >(); + const ensure = (channel: string) => { + let cur = map.get(channel); + if (!cur) { + cur = { + spend: 0, + platformLeads: 0, + crmLeads: 0, + cierres: 0, + ventas: 0, + }; + map.set(channel, cur); + } + return cur; + }; + for (const r of daily) { const channel = (r.platform || 'otros').toLowerCase(); - const cur = map.get(channel) || { - spend: 0, - platformLeads: 0, - crmLeads: 0, - }; + const cur = ensure(channel); cur.spend += n(r.spend); cur.platformLeads += n(r.platform_leads ?? r.conversions); - map.set(channel, cur); } for (const r of leads) { const channel = normalizeLeadChannel(r.source); - const cur = map.get(channel) || { - spend: 0, - platformLeads: 0, - crmLeads: 0, - }; + const cur = ensure(channel); cur.crmLeads += 1; - map.set(channel, cur); + if (isClienteStatus(r.status)) cur.cierres += 1; + } + + for (const d of deals) { + if (d.stage && d.stage !== 'won') continue; + const raw = (d.source || '').trim(); + const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir'; + const cur = ensure(channel); + cur.ventas += n(d.amount); } return [...map.entries()] @@ -713,10 +913,15 @@ export function attributionCross( gap: v.crmLeads - v.platformLeads, cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0, cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0, + cierres: v.cierres, + ventas: v.ventas, + roas: v.spend > 0 ? v.ventas / v.spend : 0, + cac: v.cierres > 0 ? v.spend / v.cierres : 0, })) .sort( (a, b) => b.spend - a.spend || + b.ventas - a.ventas || b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads) ); } diff --git a/src/lib/superhuman-docs.ts b/src/lib/superhuman-docs.ts index c2666df..8398a1d 100644 --- a/src/lib/superhuman-docs.ts +++ b/src/lib/superhuman-docs.ts @@ -500,23 +500,48 @@ export function matchDocsToTenants( tenants: { id: string; name: string; slug: string }[], docs: ShDoc[] ): DocMatchResult[] { + // Expand short brand names so BPS ↔ BLUE PARROT SCHOOL matches + const expanded = tenants.flatMap((t) => { + const names = tenantMatchNames(t); + return names.map((name) => ({ ...t, name })); + }); const accounts = docs.map((d) => ({ id: d.id, name: cleanClientDocName(d.name), })); - const matched = matchAccountsToTenants(tenants, accounts, { + const matched = matchAccountsToTenants(expanded, 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, - })); + // Deduplicate by original tenant id (aliases create duplicate rows) + const byTenant = new Map(); + for (const m of matched) { + if (m.status !== 'matched') continue; + const prev = byTenant.get(m.tenantId); + if (!prev || m.score > prev.score) byTenant.set(m.tenantId, m); + } + return tenants.map((t) => { + const m = byTenant.get(t.id); + if (m) { + return { + docId: m.accountId || '', + docName: + docs.find((d) => d.id === m.accountId)?.name || m.accountName || '', + tenantId: t.id, + tenantName: t.name, + score: m.score, + status: 'matched' as const, + }; + } + return { + docId: '', + docName: '', + tenantId: null, + tenantName: null, + score: 0, + status: 'unmatched' as const, + }; + }); } /** Best single-doc match for one tenant (manual zoom). */ @@ -524,19 +549,57 @@ export function bestDocForTenant( tenant: { name: string; slug: string }, docs: ShDoc[] ): { doc: ShDoc; score: number } | null { + const names = tenantMatchNames(tenant); let best: { doc: ShDoc; score: number } | null = null; for (const d of docs) { - const score = scoreNameToTenant( - cleanClientDocName(d.name), - tenant.name, - tenant.slug - ); + const cleaned = cleanClientDocName(d.name); + let score = 0; + for (const n of names) { + score = Math.max( + score, + scoreNameToTenant(cleaned, n, tenant.slug) + ); + } if (!best || score > best.score) best = { doc: d, score }; } - if (!best || best.score < 50) return null; + if (!best || best.score < 75) return null; return best; } +/** Extra names so short brands match full Superhuman / Drive titles. */ +const TENANT_DOC_ALIASES: Record = { + bps: ['Blue Parrot School', 'Blue Parrot', 'BPS'], + 'agua-miia': ['Agua Miia', 'Aguamiia', 'Agua'], + 'brava-dermoestetica': ['Brava', 'Brava Dermoestética'], + andala: ['Andala'], + 'loybee-app': ['LoyBee', 'Loybee', 'LoyBee App'], + noberplast: ['Noberplast'], + 'virginia-molet': ['Virginia Molet', 'Virginia'], + '4ventures-hub': ['4Ventures', '4VENTURES'], + 'delgado-abogados': ['Delgado Abogados', 'Delgado'], + 'equinsa-parking': ['Equinsa', 'Equinsa Parking'], + 'consigue-tus-papeles': ['Consigue Tus Papeles', 'CTP'], + 'talento-y-deporte': ['Talento y Deporte', 'Talento'], + 'la-freseria': ['La Fresería', 'Fresería', 'Freseria'], + gravitae: ['Gravitae'], + menttum: ['Menttum'], + 'wine-advisors': ['Wine Advisors', 'Wine'], + ecp: ['ECP'], + mcd: ['MCD'], + 'fbm-abogados': ['FBM Abogados', 'FBM', 'Herencias'], + 'clinica-sonrisas': ['Clínica Dental Sonrisas', 'Sonrisas', 'Clinica Sonrisas'], + 'sumar-inversion': ['Sumar Inversión', 'Sumar'], +}; + +function tenantMatchNames(tenant: { name: string; slug: string }): string[] { + const out = [tenant.name]; + const aliases = TENANT_DOC_ALIASES[tenant.slug.toLowerCase()] || []; + for (const a of aliases) { + if (!out.some((x) => normalizeName(x) === normalizeName(a))) out.push(a); + } + return out; +} + type DriveDocHit = { id: string; name: string; @@ -603,7 +666,25 @@ export async function findSeguimientoGoogleDoc( return scoreDriveHits(files2, cleaned); } const files = ((await r.json()) as { files?: DriveDocHit[] }).files || []; - return scoreDriveHits(files, cleaned); + const scored = scoreDriveHits(files, cleaned); + if (scored) return scored; + + // Also search «Seguimiento …» without «Semanal» (e.g. «Seguimiento BPS») + const p2 = new URLSearchParams({ + q: "name contains 'Seguimiento' and mimeType='application/vnd.google-apps.document' and trashed=false", + fields: 'files(id,name,webViewLink,modifiedTime)', + pageSize: '50', + corpora: 'allDrives', + includeItemsFromAllDrives: 'true', + supportsAllDrives: 'true', + }); + const r2 = await fetch(`https://www.googleapis.com/drive/v3/files?${p2}`, { + headers: { Authorization: `Bearer ${token}` }, + cache: 'no-store', + }); + if (!r2.ok) return null; + const files2 = ((await r2.json()) as { files?: DriveDocHit[] }).files || []; + return scoreDriveHits(files2, cleaned); } function scoreDriveHits( @@ -613,7 +694,11 @@ function scoreDriveHits( let best: { file: DriveDocHit; score: number } | null = null; for (const f of files) { const fn = normalizeName(f.name || ''); - let score = /seguimiento\s+semanal/.test(fn) ? 20 : 0; + let score = /seguimiento\s+semanal/.test(fn) + ? 20 + : /\bseguimiento\b/.test(fn) + ? 10 + : 0; for (const h of cleaned) { const hn = normalizeName(h); if (!hn) continue; @@ -624,10 +709,10 @@ function scoreDriveHits( .split(' ') .filter( (t) => - t.length > 3 && t !== 'seguimiento' && t !== 'semanal' + t.length > 2 && t !== 'seguimiento' && t !== 'semanal' ); const hits = tokens.filter((t) => fn.includes(t)).length; - if (tokens.length && hits / tokens.length >= 0.6) { + if (tokens.length && hits / tokens.length >= 0.5) { score = Math.max(score, 70 + hits * 5); } } @@ -742,20 +827,30 @@ export async function buildSeguimientoRowsForDoc(opts: { const now = new Date().toISOString(); const rows: SeguimientoSyncRow[] = []; - // 1) Google Doc «Seguimiento Semanal …» — HTML completo partido por fecha + // 1) Google Doc «Seguimiento Semanal …» / «Seguimiento {cliente}» — HTML por día + const slugGuess = /\bbps\b|parrot/i.test(opts.tenantName || '') + ? 'bps' + : (opts.tenantName || '').toLowerCase().replace(/\s+/g, '-'); + const aliasHints = opts.tenantName + ? tenantMatchNames({ name: opts.tenantName, slug: slugGuess }) + : ['BPS', 'Blue Parrot School']; const nameHints = [ ...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name), - opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '', - opts.tenantName ? `Seguimiento ${opts.tenantName}` : '', + ...aliasHints.flatMap((n) => [ + `Seguimiento Semanal ${n}`, + `Seguimiento ${n}`, + ]), + 'Seguimiento BPS', ].filter(Boolean); if (exportBodies && nameHints.length) { try { const hit = await findSeguimientoGoogleDoc(nameHints); if (hit) { - const html = await exportGoogleDocHtml(hit.id); - rows.push( - ...dayRowsFromHtml({ + let dayRows: SeguimientoSyncRow[] = []; + try { + const html = await exportGoogleDocHtml(hit.id); + dayRows = dayRowsFromHtml({ tenantId: opts.tenantId, html, fileId: hit.id, @@ -764,8 +859,37 @@ export async function buildSeguimientoRowsForDoc(opts: { `https://docs.google.com/document/d/${hit.id}/edit`, maxEntries, now, - }) - ); + }); + } catch { + // Docs grandes: HTML export 403 «too large» → plain text + const plain = await exportGoogleDocPlain(hit.id); + const sections = splitNotesByDay(plain).slice(0, maxEntries); + dayRows = sections.map((sec) => ({ + tenant_id: opts.tenantId, + source: 'superhuman' as const, + entry_type: 'meeting' as const, + external_id: `gdoc:${hit.id}:${sec.dateIso}`, + title: `Seguimiento · ${sec.label}`, + summary: + sec.body + .split('\n') + .map((l) => l.replace(/^[\s*•\-]+/, '').trim()) + .find((l) => l.length > 8) + ?.slice(0, 220) || null, + body_md: `
${sec.body
+              .replace(/&/g, '&')
+              .replace(//g, '>')}
`, + browser_link: + hit.webViewLink || + `https://docs.google.com/document/d/${hit.id}/edit`, + metrics: parseMetricsFromMarkdown(sec.body), + occurred_at: `${sec.dateIso}T12:00:00.000Z`, + synced_at: now, + updated_at: now, + })); + } + rows.push(...dayRows); } } catch { // fall through to canvas pages @@ -905,3 +1029,141 @@ export async function patchSuperhumanIntegration( export function docDisplayHint(name: string): string { return normalizeName(cleanClientDocName(name)); } + +export type SuperhumanSyncResult = { + tenantId: string; + tenantName: string; + docId: string | null; + docName: string | null; + upserted: number; + status: 'synced' | 'unmatched' | 'error'; + error?: string; +}; + +/** Sync seguimiento for all (or one) tenants with Superhuman/Drive notes. */ +export async function syncAllSuperhumanTenants(opts?: { + tenantId?: string | null; + maxEntries?: number; + exportBodies?: boolean; +}): Promise<{ synced: number; results: SuperhumanSyncResult[] }> { + if (!superhumanConfigured()) { + throw new Error('Falta SUPERHUMAN_DOCS_API_KEY'); + } + const headers = serviceHeaders(); + if (!headers) throw new Error('no service role'); + + 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) throw new Error(await tRes.text()); + let tenants = (await tRes.json()) as { + id: string; + name: string; + slug: string; + }[]; + if (opts?.tenantId) { + tenants = tenants.filter((t) => t.id === opts.tenantId); + } + + const maxEntries = Math.min(opts?.maxEntries ?? 100, 150); + const exportBodies = opts?.exportBodies !== false; + const results: SuperhumanSyncResult[] = []; + + for (const tenant of tenants) { + let docId: string | null = null; + let docName: string | null = null; + + 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, + tenantName: tenant.name, + 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 { + synced: results.filter((r) => r.status === 'synced').length, + results, + }; +} + diff --git a/src/middleware.ts b/src/middleware.ts index 18a3124..afb56bb 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -52,11 +52,12 @@ export async function middleware(req: NextRequest) { const url = req.nextUrl.clone(); const path = url.pathname; - // Public: login, attribution script, health, inbound webhooks (CRM) + // Public: login, attribution script, health, inbound webhooks (CRM), cron (Bearer CRON_SECRET) const isPublic = path === '/login' || path === '/api/health' || path.startsWith('/api/webhooks/') || + path.startsWith('/api/cron/') || path === '/_4v_attr.js' || path.startsWith('/_next') || path === '/favicon.ico' ||