feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal

Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite
histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-28 10:23:36 +02:00
parent dd50251de2
commit cf27eba74d
30 changed files with 2367 additions and 514 deletions

View File

@@ -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

75
scripts/sync_ads_only.ts Normal file
View File

@@ -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);
});

272
scripts/sync_all_data.ts Normal file
View File

@@ -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<T>(path: string): Promise<T> {
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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<Tenant[]>(
'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);
});

View File

@@ -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_<SLUG>)
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())

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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).*`);
}
}

View File

@@ -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);
}

View File

@@ -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<string, unknown> = {
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);
}

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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<string, unknown> | 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<string, unknown> = {};
if (r.ok) {
const rows = (await r.json()) as { meta?: Record<string, unknown> }[];
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;
}
}
}

View File

@@ -110,8 +110,13 @@ async function resolveTenantId(slugOrId: string | null): Promise<string | null>
const base = supabaseUrl();
// UUID?
if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId;
// Legacy slug aliases (renames)
const SLUG_ALIASES: Record<string, string> = {
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<string, unknown> = {
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=<slug>',
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia',
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=<slug>&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=<slug>&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,
}),
});
}

View File

@@ -15,6 +15,9 @@ async function fetchCrmLeads(
includeTenantId: boolean
): Promise<CrmLeadRow[]> {
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;

View File

@@ -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<CrmLeadRow[]> {
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}
/>
);
}

View File

@@ -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<string, string> = {
meta_ads: 'act_… (Ad Account ID)',
@@ -219,6 +219,11 @@ export default function ClientIntegrationsPanel({
<div>
<p className="text-sm font-medium text-gray-900">
{p.label}
{p.live === false && (
<span className="ml-2 text-[10px] font-normal text-gray-500">
(sin conector API aún)
</span>
)}
</p>
{folderHint && (
<p className="text-[11px] text-brand-700 mt-0.5">

View File

@@ -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"
/>
</Field>
<Field label="Canal">

View File

@@ -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<CrmDateLens, string> = {
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<CrmDateLens>('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).`}
/>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-600 shrink-0">
Lente CRM
</p>
<div className="flex flex-wrap gap-1.5">
{(
[
['created', 'Altas en periodo'],
['status', 'Conversiones en periodo'],
['snapshot', 'Estado actual'],
] as const
).map(([id, lab]) => (
<button
key={id}
type="button"
onClick={() => setLens(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
lens === id
? 'bg-brand-500 text-white border-brand-500'
: 'bg-white text-gray-700 border-gray-200 hover:border-brand-300'
}`}
>
{lab}
</button>
))}
</div>
</div>
<p className="text-xs text-gray-600 -mt-4">{LENS_HELP[lens]}</p>
{lens === 'status' && !hasStatusTiming ? (
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
Aún no hay <code className="font-mono">status_changed_at</code> en
warehouse — aplicando filtro por alta. Ejecuta{' '}
<code className="font-mono">sql/leads_status_timing.sql</code> en
Supabase y deja que los webhooks actualicen contactos.
</p>
) : null}
{!crmLeads.length ? (
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3">
Sin leads Clientify en este contexto. Importa el histórico o activa el
@@ -97,7 +152,6 @@ export default function CrmDashboard({
</p>
) : null}
{/* North star */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{[
{
@@ -118,12 +172,12 @@ export default function CrmDashboard({
{
label: 'En oportunidad',
value: formatNum(funnel.enOportunidad),
icon: <Briefcase className="w-4 h-4 text-sky-400" />,
icon: <Briefcase className="w-4 h-4 text-violet-400" />,
},
{
label: 'Lead frío*',
label: 'Lead frío',
value: formatNum(funnel.leadFrio),
icon: <Flame className="w-4 h-4 text-amber-400" />,
icon: <Flame className="w-4 h-4 text-sky-400" />,
},
{
label: 'Lead perdido',
@@ -133,65 +187,96 @@ export default function CrmDashboard({
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-gray-200 bg-white px-4 py-3"
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
>
<div className="flex items-center justify-between text-gray-500 mb-1">
<p className="text-[10px] uppercase tracking-wider">{s.label}</p>
<div className="flex items-center justify-between text-gray-600 mb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider">
{s.label}
</span>
{s.icon}
</div>
<p className="text-xl font-bold text-gray-900">{s.value}</p>
<p className="text-xl font-bold text-gray-900 tabular-nums">
{s.value}
</p>
</div>
))}
</div>
<p className="text-[11px] text-gray-600 -mt-5">
*Frío incluye templado / caliente / cold-lead.
</p>
{/* % cliente por canal — paid first */}
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
<div>
<h3 className="text-sm font-semibold text-gray-900">
% a cliente por canal
</h3>
<p className="text-[11px] text-gray-500 mt-0.5">
<p className="text-xs text-gray-600 mt-0.5">
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
(ganado). Paid arriba; resto de atribución abajo.
(ganado). Ads spend del periodo es referencia.
</p>
</div>
{channelRows.length === 0 ? (
<p className="text-xs text-gray-500 py-6 text-center">
Sin canales en el periodo.
{!channelRows.length ? (
<p className="text-sm text-gray-500 py-4 text-center">
Sin leads en esta lente/periodo.
</p>
) : (
<div className="overflow-x-auto space-y-6">
<ChannelTable
title="Paid (Meta / Google)"
rows={paidChannels}
spendByChannel={spendByChannel}
/>
<ChannelTable
title="Resto de canales CRM"
rows={otherChannels}
spendByChannel={spendByChannel}
/>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200">
<tr>
<th className="py-2 pr-3 font-medium">Canal</th>
<th className="py-2 pr-3 font-medium">Leads</th>
<th className="py-2 pr-3 font-medium">Clientes</th>
<th className="py-2 pr-3 font-medium">% a cliente</th>
<th className="py-2 font-medium">Inversión ads</th>
</tr>
</thead>
<tbody>
{[...paidChannels, ...otherChannels].map((row) => (
<tr
key={row.channel}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 font-semibold capitalize text-gray-800">
{row.channel}
</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
{formatNum(row.leads)}
</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
{formatNum(row.cliente)}
</td>
<td
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
>
{row.clienteRate.toFixed(1)}%
</td>
<td className="py-2.5 tabular-nums text-gray-700">
{spendByChannel.get(row.channel)
? formatEur(spendByChannel.get(row.channel)!)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-[10px] text-gray-500">
La inversión por canal (si hay ads) es solo referencia; el % a cliente
es CRM. Lente activa: {lens}.
</p>
</section>
{/* Funnel statuses */}
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
<h3 className="text-sm font-semibold text-gray-900">Funnel por estado</h3>
{statusRows.length === 0 ? (
<p className="text-xs text-gray-500 py-4 text-center">Sin datos.</p>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
<h3 className="text-sm font-semibold text-gray-900">Desglose de status</h3>
{!statusRows.length ? (
<p className="text-sm text-gray-500 py-4 text-center">Sin datos.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead className="text-gray-500 border-b border-gray-200">
<table className="w-full text-left text-sm">
<thead className="text-gray-600 border-b border-gray-200">
<tr>
<th className="py-2 pr-3 font-medium">Estado</th>
<th className="py-2 pr-3 font-medium">Leads</th>
<th className="py-2 font-medium">% del total</th>
<th className="py-2 pr-3 font-medium">Status</th>
<th className="py-2 pr-3 font-medium">N</th>
<th className="py-2 font-medium">%</th>
</tr>
</thead>
<tbody>
@@ -200,13 +285,11 @@ export default function CrmDashboard({
key={row.status}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 text-gray-800 capitalize">
{row.status}
</td>
<td className="py-2.5 pr-3 text-gray-800">
<td className="py-2.5 pr-3 text-gray-800">{row.status}</td>
<td className="py-2.5 pr-3 tabular-nums">
{formatNum(row.count)}
</td>
<td className="py-2.5 text-gray-800">
<td className="py-2.5 tabular-nums text-gray-600">
{row.pct.toFixed(1)}%
</td>
</tr>
@@ -216,91 +299,6 @@ export default function CrmDashboard({
</div>
)}
</section>
<p className="text-[11px] text-gray-600">
La inversión por canal (si hay ads) es solo referencia; el % a cliente es
100% CRM (Clientify).
</p>
</div>
);
}
function ChannelTable({
title,
rows,
spendByChannel,
}: {
title: string;
rows: CrmChannelStats[];
spendByChannel: Map<string, number>;
}) {
if (!rows.length) {
return (
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
{title}
</p>
<p className="text-xs text-gray-600">Sin filas.</p>
</div>
);
}
return (
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
{title}
</p>
<table className="w-full text-left text-xs">
<thead className="text-gray-500 border-b border-gray-200">
<tr>
<th className="py-2 pr-3 font-medium">Canal</th>
<th className="py-2 pr-3 font-medium">Leads</th>
<th className="py-2 pr-3 font-medium">Clientes</th>
<th className="py-2 pr-3 font-medium">% a cliente</th>
<th className="py-2 pr-3 font-medium">En oportunidad</th>
<th className="py-2 pr-3 font-medium">Frío*</th>
<th className="py-2 pr-3 font-medium">Perdido</th>
<th className="py-2 font-medium">Spend ads</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const spend = spendByChannel.get(row.channel) || 0;
return (
<tr
key={row.channel}
className="border-b border-gray-100 hover:bg-gray-50"
>
<td className="py-2.5 pr-3 text-brand-700 font-semibold capitalize">
{row.channel}
</td>
<td className="py-2.5 pr-3 text-gray-800">
{formatNum(row.leads)}
</td>
<td className="py-2.5 pr-3 text-emerald-300 font-medium">
{formatNum(row.cliente)}
</td>
<td
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
>
{row.clienteRate.toFixed(1)}%
</td>
<td className="py-2.5 pr-3 text-gray-800">
{formatNum(row.enOportunidad)}
</td>
<td className="py-2.5 pr-3 text-gray-800">
{formatNum(row.leadFrio)}
</td>
<td className="py-2.5 pr-3 text-gray-800">
{formatNum(row.leadPerdido)}
</td>
<td className="py-2.5 text-gray-400">
{spend ? formatEur(spend) : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -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<Ctx | null>(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<RangePreset>('last_7d');
const [preset, setPresetState] = useState<RangePreset>('last_30d');
const [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = useState('');
const [hydrated, setHydrated] = useState(false);

View File

@@ -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 (
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center">
Sin cierres en el periodo — sincroniza deals Clientify
Sin cierres en este rango — amplía el periodo o revisa deals ganados en
Clientify
</p>
);
}
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 (
<div className="mt-3 grid grid-cols-3 gap-1.5">
@@ -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 (
<div
key={c.label}
@@ -99,6 +112,27 @@ function DeltaTriple({
<Icon className="w-3.5 h-3.5" />
{formatPct(c.v)}
</p>
{hasBase ? (
<>
<p className="text-[11px] font-semibold text-gray-800 mt-1 tabular-nums leading-tight">
prev {format(c.base ?? 0)}
</p>
{absDelta !== null && (
<p
className={`text-[10px] font-medium tabular-nums leading-tight ${color}`}
>
Δ{' '}
{absDelta > 0
? `+${format(absDelta)}`
: format(absDelta)}
</p>
)}
</>
) : (
<p className="text-[10px] text-gray-500 mt-1 leading-tight">
sin histórico
</p>
)}
</div>
);
})}
@@ -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<string, { spend: number; leads: number }>();
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: <TrendingUp className="w-4 h-4 text-emerald-600" />,
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: <Target className="w-4 h-4 text-amber-600" />,
@@ -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: <TrendingUp className="w-4 h-4 text-emerald-700" />,
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 && (
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
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 <strong>{rangeHint}</strong>: 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.
</p>
)}
{cohortFromLeads && hasCohort && (
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
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.
</p>
)}
@@ -350,7 +440,13 @@ export default function MetricsDashboard({
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{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 (
<div
key={c.key}
@@ -367,9 +463,11 @@ export default function MetricsDashboard({
</p>
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
<DeltaTriple
delta={deltas[c.key]}
delta={rangeDeltas[c.key] ?? deltas[c.key]}
invert={c.invert}
empty={emptyCohort}
format={c.format}
current={periodTotals[c.key]}
/>
</div>
);
@@ -383,16 +481,9 @@ export default function MetricsDashboard({
Cruce por canal · ads vs CRM
</h3>
<p className="text-xs text-gray-600 mt-0.5">
Gasto y leads de plataforma vs leads CRM (como en{' '}
<a
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
target="_blank"
rel="noreferrer"
className="text-brand-600 underline"
>
combinación de datos
</a>
): 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{' '}
<span className="font-medium">sin-atribuir</span>.
</p>
</div>
<p className="text-xs text-gray-600 shrink-0 font-medium">
@@ -401,16 +492,17 @@ export default function MetricsDashboard({
</div>
{channelInsight && (
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
<strong>Resumen:</strong> más inversión en{' '}
<span className="capitalize font-semibold">
{channelInsight.top.channel}
</span>{' '}
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '}
<strong>Resumen:</strong> 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{' '}
<span className="capitalize font-semibold">
{channelInsight.worstGap.channel}
</span>{' '}
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
{formatNum(channelInsight.worstGap.gap)}).
</span>
.
</p>
)}
{attributionRows.length === 0 ? (
@@ -427,6 +519,9 @@ export default function MetricsDashboard({
<th className="py-2 pr-3 font-semibold">Leads plat</th>
<th className="py-2 pr-3 font-semibold">Leads CRM</th>
<th className="py-2 pr-3 font-semibold">Gap</th>
<th className="py-2 pr-3 font-semibold">Cierres</th>
<th className="py-2 pr-3 font-semibold">Ventas</th>
<th className="py-2 pr-3 font-semibold">ROAS</th>
<th className="py-2 pr-3 font-semibold">CPL plat</th>
<th className="py-2 font-semibold">CPL CRM</th>
</tr>
@@ -469,6 +564,15 @@ export default function MetricsDashboard({
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{hasCrm ? formatNum(row.cierres) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
{row.ventas > 0 ? formatEur(row.ventas) : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
</td>
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.cplPlatform
? `${formatNum(row.cplPlatform)} €`
@@ -482,6 +586,70 @@ export default function MetricsDashboard({
</tr>
))}
</tbody>
{attributionRows.length > 0 && (
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="py-2.5 pr-3 text-gray-900">Total</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
{attributionTotals.spend
? formatEur(attributionTotals.spend)
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{formatNum(attributionTotals.platformLeads)}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
</td>
<td
className={`py-2.5 pr-3 tabular-nums ${
hasCrm
? gapClass(attributionTotals.gap)
: 'text-gray-500'
}`}
>
{hasCrm
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{hasCrm ? formatNum(attributionTotals.cierres) : '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
{attributionTotals.ventas > 0 || periodTotals.revenue > 0
? formatEur(
attributionTotals.ventas || periodTotals.revenue
)
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{attributionTotals.spend > 0 &&
(attributionTotals.ventas || periodTotals.revenue) > 0
? `${formatNum(
(attributionTotals.ventas ||
periodTotals.revenue) / attributionTotals.spend
)}x`
: '—'}
</td>
<td className="py-2.5 pr-3 tabular-nums">
{attributionTotals.platformLeads > 0
? `${formatNum(
attributionTotals.spend /
attributionTotals.platformLeads
)} €`
: '—'}
</td>
<td className="py-2.5 tabular-nums">
{hasCrm && attributionTotals.crmLeads > 0
? `${formatNum(
attributionTotals.spend /
attributionTotals.crmLeads
)} €`
: '—'}
</td>
</tr>
</tfoot>
)}
</table>
</div>
)}
@@ -498,13 +666,13 @@ export default function MetricsDashboard({
{dailyChart.map((d) => (
<div
key={`s-${d.date}`}
className="flex-1 min-w-0"
className="flex-1 min-w-0 h-full flex items-end"
title={`${d.date}: ${formatEur(d.spend)}`}
>
<div
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
style={{
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`,
height: `${Math.max(4, (d.spend / maxSpend) * 80)}px`,
}}
/>
</div>
@@ -523,13 +691,18 @@ export default function MetricsDashboard({
{dailyChart.map((d) => (
<div
key={`l-${d.date}`}
className="flex-1 min-w-0"
className="flex-1 min-w-0 h-full flex items-end"
title={`${d.date}: ${formatNum(d.leads)} leads`}
>
<div
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
style={{
height: `${Math.max(4, (d.leads / Math.max(1, ...dailyChart.map((x) => x.leads))) * 100)}%`,
height: `${Math.max(
4,
(d.leads /
Math.max(1, ...dailyChart.map((x) => x.leads))) *
80
)}px`,
}}
/>
</div>

View File

@@ -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 {

View File

@@ -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<AdDailyInsert[]> {
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<AdDailyInsert[]> {
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<AdDailyInsert[]> {
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<AdDailyInsert[]> {
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<AdDailyInsert[]> {
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<string, Agg>();
@@ -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'

View File

@@ -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 =

26
src/lib/cron-auth.ts Normal file
View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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;

View File

@@ -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<keyof MetricTotals, Delta> {
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<keyof MetricTotals, Delta>;
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)
);
}

View File

@@ -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<string, (typeof matched)[number]>();
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<string, string[]> = {
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: `<pre>${sec.body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</pre>`,
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,
};
}

View File

@@ -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' ||