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 - `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) - 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` - 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 ## Verificación
@@ -54,3 +76,4 @@ Nunca pegar keys en PDFs ni `.env.example`.
- Rol `client` → redirigido fuera de agencia - Rol `client` → redirigido fuera de agencia
- Chat/simulate solo con sesión - Chat/simulate solo con sesión
- Health: `serviceRoleConfigured: true` - 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 = const tenantId =
typeof body.tenantId === 'string' ? body.tenantId : undefined; typeof body.tenantId === 'string' ? body.tenantId : undefined;
const days = const days =
typeof body.days === 'number' && body.days > 0 && body.days <= 90 typeof body.days === 'number' && body.days > 0 && body.days <= 1000
? body.days ? body.days
: 30; : 30;

View File

@@ -7,14 +7,8 @@ import {
userRole, userRole,
} from '@/lib/agency'; } from '@/lib/agency';
import { import {
SUPERHUMAN_PROVIDER, syncAllSuperhumanTenants,
bestDocForTenant,
buildSeguimientoRowsForDoc,
listDocs,
matchDocsToTenants,
patchSuperhumanIntegration,
superhumanConfigured, superhumanConfigured,
upsertSeguimientoRows,
} from '@/lib/superhuman-docs'; } from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -51,6 +45,9 @@ export async function GET() {
if ('error' in gate && gate.error) return gate.error; if ('error' in gate && gate.error) return gate.error;
try { try {
const { listDocs, matchDocsToTenants, SUPERHUMAN_PROVIDER } = await import(
'@/lib/superhuman-docs'
);
const headers = (gate as { headers: { apikey: string; Authorization: string } }) const headers = (gate as { headers: { apikey: string; Authorization: string } })
.headers; .headers;
const [docs, tenantsRes] = await Promise.all([ const [docs, tenantsRes] = await Promise.all([
@@ -91,8 +88,6 @@ export async function GET() {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const gate = await requireAgency(); const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error; 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 body = await req.json().catch(() => ({}));
const onlyTenant: string | undefined = body.tenantId 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); const maxEntries = Math.min(Number(body.maxEntries) || 80, 150);
try { try {
const docs = await listDocs(); const result = await syncAllSuperhumanTenants({
const tRes = await fetch( tenantId: onlyTenant || null,
`${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, maxEntries,
exportBodies, exportBodies,
}); });
const upserted = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(tenant.id, {
status: 'connected',
external_account_id: docId,
label: docName || 'Superhuman Docs',
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
doc_name: docName,
entries: upserted,
synced_at: new Date().toISOString(),
},
});
results.push({
tenantId: tenant.id,
tenantName: tenant.name,
docId,
docName,
upserted,
status: 'synced',
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'sync failed';
await patchSuperhumanIntegration(tenant.id, {
status: 'error',
external_account_id: docId,
error_message: msg.slice(0, 400),
});
results.push({
tenantId: tenant.id,
tenantName: tenant.name,
docId,
docName,
upserted: 0,
status: 'error',
error: msg,
});
}
}
return NextResponse.json({ return NextResponse.json({
ok: true, ok: true,
results, ...result,
synced: results.filter((r) => r.status === 'synced').length,
}); });
} catch (e) { } catch (e) {
return NextResponse.json( 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}**` `${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'); ).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 { } else {
return reply('📢 Actualmente no constan campañas activas con inversión registrada en los últimos 30 días para este portal.'); 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> ')}` `📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
).join('\n\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 { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllConnectedOrganic } from '@/lib/google-organic'; import { syncAllConnectedOrganic } from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive'; import { getAgencyOAuthStatus } from '@/lib/google-drive';
@@ -6,20 +7,12 @@ export const dynamic = 'force-dynamic';
export const maxDuration = 300; 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 * 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) { export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET?.trim(); const denied = assertCronAuth(req);
const auth = req.headers.get('authorization') || ''; if (denied) return denied;
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 status = await getAgencyOAuthStatus(); const status = await getAgencyOAuthStatus();
if (!status.connected) { 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( const days = Math.min(
28, 28,
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7) 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 { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; 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'; import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -18,6 +18,73 @@ type Integ = {
meta?: Record<string, unknown> | null; 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 = { type SocialPost = {
id: string; id: string;
platform: 'facebook' | 'instagram'; platform: 'facebook' | 'instagram';
@@ -585,10 +652,6 @@ export async function GET(req: NextRequest) {
const posts: SocialPost[] = []; const posts: SocialPost[] = [];
const tokens = await resolveMetaAccessTokens(); const tokens = await resolveMetaAccessTokens();
if (!tokens.length) {
metrics.warning =
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
} else {
const fbInteg = integrations.find( const fbInteg = integrations.find(
(i) => (i) =>
i.provider === 'facebook' && i.provider === 'facebook' &&
@@ -604,6 +667,19 @@ export async function GET(req: NextRequest) {
!i.external_account_id.startsWith('http') !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 token = const token =
(await pickWorkingToken(tokens, fbInteg?.external_account_id)) || (await pickWorkingToken(tokens, fbInteg?.external_account_id)) ||
(await pickWorkingToken(tokens, igInteg?.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)) { if ('posts' in fb && Array.isArray(fb.posts)) {
posts.push(...(fb.posts as SocialPost[])); posts.push(...(fb.posts as SocialPost[]));
} }
await cacheSocialSnapshot(ctx.tenantId, 'facebook', fb);
} }
if (igInteg?.external_account_id) { if (igInteg?.external_account_id) {
const ig = await fetchIgBundle( const ig = await fetchIgBundle(
@@ -633,9 +710,18 @@ export async function GET(req: NextRequest) {
if ('posts' in ig && Array.isArray(ig.posts)) { if ('posts' in ig && Array.isArray(ig.posts)) {
posts.push(...(ig.posts as SocialPost[])); posts.push(...(ig.posts as SocialPost[]));
} }
await cacheSocialSnapshot(ctx.tenantId, 'instagram', ig);
} }
} catch (e) { } catch (e) {
metrics.warning = e instanceof Error ? e.message : 'metrics error'; 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(); const base = supabaseUrl();
// UUID? // UUID?
if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId; 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( 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' } { headers, cache: 'no-store' }
); );
if (!r.ok) return null; if (!r.ok) return null;
@@ -130,7 +135,59 @@ async function upsertLead(tenantId: string, c: Json) {
str(c.channel, 80) || str(c.channel, 80) ||
str(c.medium, 80) || str(c.medium, 80) ||
'clientify'; '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, id,
tenant_id: tenantId, tenant_id: tenantId,
first_name: first_name:
@@ -142,19 +199,45 @@ async function upsertLead(tenantId: string, c: Json) {
medium: str(c.medium, 80), medium: str(c.medium, 80),
crm: 'clientify', crm: 'clientify',
crm_contact_id: cid, 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( await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}`, `${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' } } { 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', method: 'POST',
headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' }, headers: {
...headers,
Prefer: 'return=minimal',
'Content-Type': 'application/json',
},
body: JSON.stringify(row), 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) { if (!ins.ok) {
throw new Error(`lead insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`); 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', service: 'clientify-webhook',
tenant: tenant || null, tenant: tenant || null,
hint: 'POST events from Clientify. Use ?tenant=<slug>', 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 secretEnv = process.env.CLIENTIFY_WEBHOOK_SECRET?.trim();
const secretQ = req.nextUrl.searchParams.get('secret'); const secretQ = req.nextUrl.searchParams.get('secret');
const secretH = req.headers.get('x-clientify-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 }); return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
} }
@@ -238,7 +331,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
error: 'missing_or_unknown_tenant', 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 } { status: 400 }
); );
@@ -260,10 +353,12 @@ export async function POST(req: NextRequest) {
try { try {
if (entity === 'contact') { if (entity === 'contact') {
const r = await upsertLead(tenantId, payload); const r = await upsertLead(tenantId, payload);
await markClientifyConnected(tenantId, tenantParam);
return NextResponse.json({ ok: true, entity, ...r }); return NextResponse.json({ ok: true, entity, ...r });
} }
if (entity === 'deal') { if (entity === 'deal') {
const r = await upsertDeal(tenantId, payload); const r = await upsertDeal(tenantId, payload);
await markClientifyConnected(tenantId, tenantParam);
return NextResponse.json({ ok: true, entity, ...r }); return NextResponse.json({ ok: true, entity, ...r });
} }
// company/activity/budget/product: acknowledge for now // company/activity/budget/product: acknowledge for now
@@ -279,3 +374,52 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: msg }, { status: 500 }); 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 includeTenantId: boolean
): Promise<CrmLeadRow[]> { ): Promise<CrmLeadRow[]> {
const select = includeTenantId 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' ? 'tenant_id,source,created_at,status'
: 'source,created_at,status'; : 'source,created_at,status';
const pageSize = 1000; const pageSize = 1000;
@@ -27,12 +30,20 @@ async function fetchCrmLeads(
const tidFilter = tenantId const tidFilter = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}` ? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: ''; : '';
let useSelect = select;
for (let offset = 0; offset < 80000; offset += pageSize) { for (let offset = 0; offset < 80000; offset += pageSize) {
const r = await fetch( 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' } { 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[]; const chunk = (await r.json()) as CrmLeadRow[];
out.push(...chunk); out.push(...chunk);
if (chunk.length < pageSize) break; if (chunk.length < pageSize) break;
@@ -42,12 +53,24 @@ async function fetchCrmLeads(
const supabase = await createClient(); const supabase = await createClient();
for (let offset = 0; offset < 80000; offset += pageSize) { for (let offset = 0; offset < 80000; offset += pageSize) {
const { data } = await supabase let q = supabase
.from('leads') .from('leads')
.select(select) .select(select)
.eq('crm', 'clientify') .eq('crm', 'clientify')
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1); .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[]) || []; const chunk = (data as CrmLeadRow[]) || [];
out.push(...chunk); out.push(...chunk);
if (chunk.length < pageSize) break; if (chunk.length < pageSize) break;

View File

@@ -12,6 +12,7 @@ import {
buildComparisons, buildComparisons,
mergeDealsIntoWeekly, mergeDealsIntoWeekly,
resolveWeeklyRows, resolveWeeklyRows,
resolveWonCohort,
sumDaily, sumDaily,
sumWeekly, sumWeekly,
} from '@/lib/metrics'; } from '@/lib/metrics';
@@ -19,12 +20,13 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc
export const dynamic = 'force-dynamic'; 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( async function fetchCrmLeads(
tenantId: string | null, tenantId: string | null,
useServiceRole: boolean useServiceRole: boolean
): Promise<CrmLeadRow[]> { ): 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 pageSize = 1000;
const out: CrmLeadRow[] = []; const out: CrmLeadRow[] = [];
@@ -35,12 +37,20 @@ async function fetchCrmLeads(
const tidFilter = tenantId const tidFilter = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}` ? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: ''; : '';
let useSelect = select;
for (let offset = 0; offset < 50000; offset += pageSize) { for (let offset = 0; offset < 50000; offset += pageSize) {
const r = await fetch( 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' } { 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[]; const chunk = (await r.json()) as CrmLeadRow[];
out.push(...chunk); out.push(...chunk);
if (chunk.length < pageSize) break; if (chunk.length < pageSize) break;
@@ -50,12 +60,23 @@ async function fetchCrmLeads(
const supabase = await createClient(); const supabase = await createClient();
for (let offset = 0; offset < 50000; offset += pageSize) { for (let offset = 0; offset < 50000; offset += pageSize) {
const { data } = await supabase let { data, error } = await supabase
.from('leads') .from('leads')
.select(select) .select(select)
.eq('crm', 'clientify') .eq('crm', 'clientify')
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1); .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[]) || []; const chunk = (data as CrmLeadRow[]) || [];
out.push(...chunk); out.push(...chunk);
if (chunk.length < pageSize) break; if (chunk.length < pageSize) break;
@@ -163,6 +184,9 @@ export default async function KpisPage() {
wonDeals = await fetchWonDeals(ctx.tenantId, false); wonDeals = await fetchWonDeals(ctx.tenantId, false);
} }
const cohort = resolveWonCohort(wonDeals, crmLeads);
wonDeals = cohort.deals;
weekly = mergeDealsIntoWeekly(resolveWeeklyRows(weekly, daily), wonDeals); weekly = mergeDealsIntoWeekly(resolveWeeklyRows(weekly, daily), wonDeals);
const comparisons = buildComparisons(weekly); const comparisons = buildComparisons(weekly);
@@ -188,6 +212,7 @@ export default async function KpisPage() {
dailyRows={daily} dailyRows={daily}
crmLeads={crmLeads} crmLeads={crmLeads}
wonDeals={wonDeals} wonDeals={wonDeals}
cohortSource={cohort.source}
/> />
); );
} }

View File

@@ -16,7 +16,7 @@ type Integ = {
meta?: { folder_name?: string; match_score?: number } | null; 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> = { const PLACEHOLDERS: Record<string, string> = {
meta_ads: 'act_… (Ad Account ID)', meta_ads: 'act_… (Ad Account ID)',
@@ -219,6 +219,11 @@ export default function ClientIntegrationsPanel({
<div> <div>
<p className="text-sm font-medium text-gray-900"> <p className="text-sm font-medium text-gray-900">
{p.label} {p.label}
{p.live === false && (
<span className="ml-2 text-[10px] font-normal text-gray-500">
(sin conector API aún)
</span>
)}
</p> </p>
{folderHint && ( {folderHint && (
<p className="text-[11px] text-brand-700 mt-0.5"> <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 })) setDraft((d) => ({ ...d, brand_name: e.target.value }))
} }
className={inputCls} className={inputCls}
placeholder="Agua Miia" placeholder="Marca del cliente"
/> />
</Field> </Field>
<Field label="Canal"> <Field label="Canal">

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useMemo } from 'react'; import React, { useMemo, useState } from 'react';
import { import {
Users, Users,
UserCheck, UserCheck,
@@ -12,6 +12,7 @@ import {
import { import {
AdDailyRow, AdDailyRow,
CrmChannelStats, CrmChannelStats,
CrmDateLens,
CrmLeadRow, CrmLeadRow,
attributionCross, attributionCross,
buildCrmChannelStats, buildCrmChannelStats,
@@ -32,6 +33,15 @@ function rateClass(rate: number): string {
return 'text-gray-500'; 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({ export default function CrmDashboard({
label, label,
portfolioWide, portfolioWide,
@@ -44,11 +54,19 @@ export default function CrmDashboard({
dailyRows?: AdDailyRow[]; dailyRows?: AdDailyRow[];
}) { }) {
const { since, until, label: rangeHint } = useDateRange(); 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(() => { const rangedLeads = useMemo(() => {
if (!crmLeads.length) return []; if (!crmLeads.length) return [];
return filterLeadsByDayRange(crmLeads, since, until); const effective: CrmDateLens =
}, [crmLeads, since, until]); lens === 'status' && !hasStatusTiming ? 'created' : lens;
return filterLeadsByDayRange(crmLeads, since, until, effective);
}, [crmLeads, since, until, lens, hasStatusTiming]);
const rangedDaily = useMemo(() => { const rangedDaily = useMemo(() => {
if (!dailyRows.length) return []; 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).`} 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 ? ( {!crmLeads.length ? (
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3"> <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 Sin leads Clientify en este contexto. Importa el histórico o activa el
@@ -97,7 +152,6 @@ export default function CrmDashboard({
</p> </p>
) : null} ) : null}
{/* North star */}
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3"> <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', label: 'En oportunidad',
value: formatNum(funnel.enOportunidad), 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), 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', label: 'Lead perdido',
@@ -133,65 +187,96 @@ export default function CrmDashboard({
].map((s) => ( ].map((s) => (
<div <div
key={s.label} 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"> <div className="flex items-center justify-between text-gray-600 mb-1">
<p className="text-[10px] uppercase tracking-wider">{s.label}</p> <span className="text-[11px] font-semibold uppercase tracking-wider">
{s.label}
</span>
{s.icon} {s.icon}
</div> </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>
))} ))}
</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 shadow-sm">
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
<div> <div>
<h3 className="text-sm font-semibold text-gray-900"> <h3 className="text-sm font-semibold text-gray-900">
% a cliente por canal % a cliente por canal
</h3> </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>{' '} 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> </p>
</div> </div>
{!channelRows.length ? (
{channelRows.length === 0 ? ( <p className="text-sm text-gray-500 py-4 text-center">
<p className="text-xs text-gray-500 py-6 text-center"> Sin leads en esta lente/periodo.
Sin canales en el periodo.
</p> </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>
)}
</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>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left text-xs"> <table className="w-full text-left text-sm">
<thead className="text-gray-500 border-b border-gray-200"> <thead className="text-gray-600 border-b border-gray-200">
<tr> <tr>
<th className="py-2 pr-3 font-medium">Estado</th> <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">Leads</th>
<th className="py-2 font-medium">% del total</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>
<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-sm">
<thead className="text-gray-600 border-b border-gray-200">
<tr>
<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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -200,13 +285,11 @@ export default function CrmDashboard({
key={row.status} key={row.status}
className="border-b border-gray-100 hover:bg-gray-50" className="border-b border-gray-100 hover:bg-gray-50"
> >
<td className="py-2.5 pr-3 text-gray-800 capitalize"> <td className="py-2.5 pr-3 text-gray-800">{row.status}</td>
{row.status} <td className="py-2.5 pr-3 tabular-nums">
</td>
<td className="py-2.5 pr-3 text-gray-800">
{formatNum(row.count)} {formatNum(row.count)}
</td> </td>
<td className="py-2.5 text-gray-800"> <td className="py-2.5 tabular-nums text-gray-600">
{row.pct.toFixed(1)}% {row.pct.toFixed(1)}%
</td> </td>
</tr> </tr>
@@ -216,91 +299,6 @@ export default function CrmDashboard({
</div> </div>
)} )}
</section> </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> </div>
); );
} }

View File

@@ -14,7 +14,7 @@ import {
isoYesterday, isoYesterday,
} from '@/lib/ad-tracker-range'; } from '@/lib/ad-tracker-range';
const STORAGE_KEY = '4v_global_date_range_v1'; const STORAGE_KEY = '4v_global_date_range_v2';
type Stored = { type Stored = {
preset: RangePreset; preset: RangePreset;
@@ -38,24 +38,24 @@ const DateRangeContext = createContext<Ctx | null>(null);
function loadStored(): Stored { function loadStored(): Stored {
const y = isoYesterday(); const y = isoYesterday();
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return { preset: 'last_7d', customSince: y, customUntil: y }; return { preset: 'last_30d', customSince: y, customUntil: y };
} }
try { try {
const raw = localStorage.getItem(STORAGE_KEY); 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; const j = JSON.parse(raw) as Stored;
return { return {
preset: j.preset || 'last_7d', preset: j.preset || 'last_30d',
customSince: j.customSince || y, customSince: j.customSince || y,
customUntil: j.customUntil || y, customUntil: j.customUntil || y,
}; };
} catch { } catch {
return { preset: 'last_7d', customSince: y, customUntil: y }; return { preset: 'last_30d', customSince: y, customUntil: y };
} }
} }
export function DateRangeProvider({ children }: { children: React.ReactNode }) { 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 [customSince, setCustomSince] = useState('');
const [customUntil, setCustomUntil] = useState(''); const [customUntil, setCustomUntil] = useState('');
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);

View File

@@ -24,6 +24,7 @@ import {
formatNum, formatNum,
formatPct, formatPct,
attributionCross, attributionCross,
buildRangeDeltas,
filterDailyByDayRange, filterDailyByDayRange,
filterDealsForRange, filterDealsForRange,
filterLeadsByDayRange, filterLeadsByDayRange,
@@ -50,22 +51,31 @@ function DeltaTriple({
delta, delta,
invert, invert,
empty, empty,
format,
current,
}: { }: {
delta: Delta; delta: Delta;
invert?: boolean; invert?: boolean;
empty?: boolean; empty?: boolean;
format: (v: number) => string;
current: number;
}) { }) {
if (empty) { if (empty) {
return ( return (
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center"> <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> </p>
); );
} }
const cells: { label: string; v: number | null }[] = [ const cells: {
{ label: 'WoW', v: delta.wow }, label: string;
{ label: 'MoM', v: delta.mom }, v: number | null;
{ label: 'YoY', v: delta.yoy }, 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 ( return (
<div className="mt-3 grid grid-cols-3 gap-1.5"> <div className="mt-3 grid grid-cols-3 gap-1.5">
@@ -85,6 +95,9 @@ function DeltaTriple({
: good : good
? ArrowUpRight ? ArrowUpRight
: ArrowDownRight; : ArrowDownRight;
const hasBase = c.base !== null && !(c.base === 0 && c.v === null);
const absDelta =
hasBase && c.base !== null ? current - c.base : null;
return ( return (
<div <div
key={c.label} key={c.label}
@@ -99,6 +112,27 @@ function DeltaTriple({
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
{formatPct(c.v)} {formatPct(c.v)}
</p> </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> </div>
); );
})} })}
@@ -127,6 +161,7 @@ export default function MetricsDashboard({
dailyRows = [], dailyRows = [],
crmLeads = [], crmLeads = [],
wonDeals = [], wonDeals = [],
cohortSource = 'deals',
}: { }: {
label: string; label: string;
portfolioWide: boolean; portfolioWide: boolean;
@@ -145,6 +180,7 @@ export default function MetricsDashboard({
dailyRows?: AdDailyRow[]; dailyRows?: AdDailyRow[];
crmLeads?: CrmLeadRow[]; crmLeads?: CrmLeadRow[];
wonDeals?: DealWonRow[]; wonDeals?: DealWonRow[];
cohortSource?: 'deals' | 'cliente_leads' | 'none';
}) { }) {
const { since, until, label: rangeHint } = useDateRange(); const { since, until, label: rangeHint } = useDateRange();
@@ -178,9 +214,15 @@ export default function MetricsDashboard({
return withCohortFromDeals(base, rangedDeals); return withCohortFromDeals(base, rangedDeals);
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]); }, [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(() => { const attributionRows: AttributionChannelRow[] = useMemo(() => {
if (rangedDaily.length || rangedLeads.length) { if (rangedDaily.length || rangedLeads.length || rangedDeals.length) {
return attributionCross(rangedDaily, rangedLeads); return attributionCross(rangedDaily, rangedLeads, rangedDeals);
} }
const map = new Map<string, { spend: number; leads: number }>(); const map = new Map<string, { spend: number; leads: number }>();
for (const p of channelPoints) { for (const p of channelPoints) {
@@ -200,14 +242,42 @@ export default function MetricsDashboard({
gap: -v.leads, gap: -v.leads,
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0, cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
cplCrm: 0, cplCrm: 0,
cierres: 0,
ventas: 0,
roas: 0,
cac: 0,
})) }))
.sort((a, b) => b.spend - a.spend); .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 crmPeriodTotal = rangedLeads.length;
const hasCrm = crmLeads.length > 0; const hasCrm = crmLeads.length > 0;
const hasCohort = rangedDeals.length > 0; const hasCohort = rangedDeals.length > 0;
const cierres = periodTotals.deals; const cierres = periodTotals.deals;
const cohortFromLeads = cohortSource === 'cliente_leads';
const revenueMissing =
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
const cards: Card[] = [ const cards: Card[] = [
{ {
@@ -236,8 +306,10 @@ export default function MetricsDashboard({
key: 'revenue', key: 'revenue',
label: 'Ventas (CRM)', label: 'Ventas (CRM)',
hint: hasCohort hint: hasCohort
? `${formatNum(cierres)} deals ganados · importe Clientify` ? cohortFromLeads
: 'Deals won Clientify en el periodo', ? `${formatNum(cierres)} clientes Clientify · importe no en webhook`
: `${formatNum(cierres)} deals ganados · importe Clientify`
: 'Deals won / status cliente Clientify en el periodo',
format: formatEur, format: formatEur,
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />, icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
cohort: true, cohort: true,
@@ -246,7 +318,7 @@ export default function MetricsDashboard({
key: 'cac', key: 'cac',
label: 'CAC', label: 'CAC',
hint: hasCohort hint: hasCohort
? `Inversión ads ÷ ${formatNum(cierres)} ventas` ? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}`
: 'Inversión ÷ deals ganados (no leads)', : 'Inversión ÷ deals ganados (no leads)',
format: (v) => `${formatNum(v)} €`, format: (v) => `${formatNum(v)} €`,
icon: <Target className="w-4 h-4 text-amber-600" />, icon: <Target className="w-4 h-4 text-amber-600" />,
@@ -256,7 +328,9 @@ export default function MetricsDashboard({
{ {
key: 'roas', key: 'roas',
label: 'ROAS ventas', 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`, format: (v) => `${formatNum(v)}x`,
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />, icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
cohort: true, cohort: true,
@@ -287,7 +361,11 @@ export default function MetricsDashboard({
const worstGap = [...attributionRows].sort( const worstGap = [...attributionRows].sort(
(a, b) => Math.abs(b.gap) - Math.abs(a.gap) (a, b) => Math.abs(b.gap) - Math.abs(a.gap)
)[0]; )[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]); }, [attributionRows]);
return ( return (
@@ -307,8 +385,20 @@ export default function MetricsDashboard({
{!hasCohort && ( {!hasCohort && (
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2"> <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 Ventas / CAC / ROAS vacíos en <strong>{rangeHint}</strong>: no hay
fecha de cierre en este periodo. El gasto de ads no cuenta como venta. 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> </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"> <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{cards.map((c) => { {cards.map((c) => {
const emptyCohort = Boolean(c.cohort) && !hasCohort; 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 ( return (
<div <div
key={c.key} key={c.key}
@@ -367,9 +463,11 @@ export default function MetricsDashboard({
</p> </p>
<p className="text-xs text-gray-600 mt-1">{c.hint}</p> <p className="text-xs text-gray-600 mt-1">{c.hint}</p>
<DeltaTriple <DeltaTriple
delta={deltas[c.key]} delta={rangeDeltas[c.key] ?? deltas[c.key]}
invert={c.invert} invert={c.invert}
empty={emptyCohort} empty={emptyCohort}
format={c.format}
current={periodTotals[c.key]}
/> />
</div> </div>
); );
@@ -383,16 +481,9 @@ export default function MetricsDashboard({
Cruce por canal · ads vs CRM Cruce por canal · ads vs CRM
</h3> </h3>
<p className="text-xs text-gray-600 mt-0.5"> <p className="text-xs text-gray-600 mt-0.5">
Gasto y leads de plataforma vs leads CRM (como en{' '} Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las
<a ventas de deals sin fuente de contacto aparecen en{' '}
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html" <span className="font-medium">sin-atribuir</span>.
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.
</p> </p>
</div> </div>
<p className="text-xs text-gray-600 shrink-0 font-medium"> <p className="text-xs text-gray-600 shrink-0 font-medium">
@@ -401,16 +492,17 @@ export default function MetricsDashboard({
</div> </div>
{channelInsight && ( {channelInsight && (
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2"> <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{' '} <strong>Resumen:</strong> plat {formatNum(channelInsight.platTotal)}{' '}
<span className="capitalize font-semibold"> vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '}
{channelInsight.top.channel} {channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''}
</span>{' '} {formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '} 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"> <span className="capitalize font-semibold">
{channelInsight.worstGap.channel} {channelInsight.worstGap.channel}
</span>{' '} </span>
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''} .
{formatNum(channelInsight.worstGap.gap)}).
</p> </p>
)} )}
{attributionRows.length === 0 ? ( {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 plat</th>
<th className="py-2 pr-3 font-semibold">Leads CRM</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">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 pr-3 font-semibold">CPL plat</th>
<th className="py-2 font-semibold">CPL CRM</th> <th className="py-2 font-semibold">CPL CRM</th>
</tr> </tr>
@@ -469,6 +564,15 @@ export default function MetricsDashboard({
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}` ? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
: '—'} : '—'}
</td> </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"> <td className="py-2.5 pr-3 text-gray-800 tabular-nums">
{row.cplPlatform {row.cplPlatform
? `${formatNum(row.cplPlatform)} €` ? `${formatNum(row.cplPlatform)} €`
@@ -482,6 +586,70 @@ export default function MetricsDashboard({
</tr> </tr>
))} ))}
</tbody> </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> </table>
</div> </div>
)} )}
@@ -498,13 +666,13 @@ export default function MetricsDashboard({
{dailyChart.map((d) => ( {dailyChart.map((d) => (
<div <div
key={`s-${d.date}`} 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)}`} title={`${d.date}: ${formatEur(d.spend)}`}
> >
<div <div
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500" className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
style={{ style={{
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`, height: `${Math.max(4, (d.spend / maxSpend) * 80)}px`,
}} }}
/> />
</div> </div>
@@ -523,13 +691,18 @@ export default function MetricsDashboard({
{dailyChart.map((d) => ( {dailyChart.map((d) => (
<div <div
key={`l-${d.date}`} 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`} title={`${d.date}: ${formatNum(d.leads)} leads`}
> >
<div <div
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500" className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
style={{ 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> </div>

View File

@@ -8,7 +8,7 @@ import {
} from '@/lib/match'; } from '@/lib/match';
const META_GRAPH = 'https://graph.facebook.com/v21.0'; 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 const META_OAUTH_PROVIDER = 'meta_ads';
export function metaEnvConfigured(): boolean { export function metaEnvConfigured(): boolean {

View File

@@ -4,7 +4,7 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { resolveMetaAccessToken } from '@/lib/ads-discovery'; import { resolveMetaAccessToken } from '@/lib/ads-discovery';
const META_GRAPH = 'https://graph.facebook.com/v21.0'; 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 = { export type SyncTenantResult = {
tenantId: string; tenantId: string;
@@ -20,8 +20,25 @@ type IntegrationRow = {
provider: string; provider: string;
external_account_id: string | null; external_account_id: string | null;
status?: string; 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 TenantRow = { id: string; name: string };
type AdDailyInsert = { type AdDailyInsert = {
@@ -114,22 +131,39 @@ function gadsHeaders(accessToken: string, loginCustomerId?: string) {
return h; return h;
} }
async function fetchMetaAdDaily( function isoDayUTC(d: Date): string {
accountId: string, return d.toISOString().slice(0, 10);
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),
});
/** 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[] = []; const out: AdDailyInsert[] = [];
let url: string | null = let url: string | null =
`${META_GRAPH}/${act}/insights` + `${META_GRAPH}/${act}/insights` +
@@ -177,19 +211,32 @@ async function fetchMetaAdDaily(
return out; return out;
} }
async function fetchGoogleCampaignDaily( async function fetchMetaAdDaily(
customerId: string, accountId: string,
tenantId: 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 accessToken: string
): Promise<AdDailyInsert[]> { ): 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 = ` const query = `
SELECT SELECT
campaign.id, campaign.id,
@@ -304,6 +351,23 @@ async function fetchGoogleCampaignDaily(
return out; 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( async function replaceDaily(
tenantId: string, tenantId: string,
platforms: string[], platforms: string[],
@@ -350,17 +414,23 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
since.setUTCDate(since.getUTCDate() - days); since.setUTCDate(since.getUTCDate() - days);
const sinceStr = since.toISOString().slice(0, 10); const sinceStr = since.toISOString().slice(0, 10);
const r = await fetch( const daily: {
`${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 {
date?: string; date?: string;
platform?: string; platform?: string;
spend?: number; spend?: number;
platform_leads?: 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 }; type Agg = { spend: number; leads: number };
const map = new Map<string, Agg>(); const map = new Map<string, Agg>();
@@ -401,6 +471,8 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
if (payload.length) { if (payload.length) {
// kpi_weekly may be a materialized view — best-effort only // kpi_weekly may be a materialized view — best-effort only
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`, { const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -408,7 +480,7 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=minimal', Prefer: 'resolution=merge-duplicates,return=minimal',
}, },
body: JSON.stringify(payload), body: JSON.stringify(chunk),
}); });
if (!ins.ok) { if (!ins.ok) {
const msg = await ins.text(); const msg = await ins.text();
@@ -419,9 +491,13 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`); throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
} }
} }
}
return payload.length; 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?: { export async function syncAdsForTenants(opts?: {
tenantId?: string; tenantId?: string;
days?: number; days?: number;
@@ -429,20 +505,29 @@ export async function syncAdsForTenants(opts?: {
const headers = serviceHeaders(); const headers = serviceHeaders();
if (!headers) throw new Error('no service role'); if (!headers) throw new Error('no service role');
const base = supabaseUrl(); 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( 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' } { headers, cache: 'no-store' }
); );
if (!tRes.ok) throw new Error(await tRes.text()); if (!tRes.ok) throw new Error(await tRes.text());
let tenants = (await tRes.json()) as TenantRow[]; let tenants = (await tRes.json()) as TenantRow[];
if (opts?.tenantId) { 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( 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' } { headers, cache: 'no-store' }
); );
if (!iRes.ok) throw new Error(await iRes.text()); if (!iRes.ok) throw new Error(await iRes.text());
@@ -471,40 +556,44 @@ export async function syncAdsForTenants(opts?: {
const platforms: string[] = []; const platforms: string[] = [];
const meta = mine.find((i) => i.provider === 'meta_ads' && i.external_account_id); 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 { try {
const mrows = await fetchMetaAdDaily( const mrows = await fetchMetaAdDaily(accountId, tenant.id, days);
meta.external_account_id,
tenant.id,
days
);
rows.push(...mrows); rows.push(...mrows);
platforms.push('meta'); result.metaRows += mrows.length;
result.metaRows = mrows.length;
} catch (e) { } catch (e) {
result.errors.push( result.errors.push(
e instanceof Error ? e.message : 'meta sync error' e instanceof Error ? e.message : 'meta sync error'
); );
} }
} }
if (result.metaRows > 0) platforms.push('meta');
const gads = mine.find( const gads = mine.find(
(i) => i.provider === 'google_ads' && i.external_account_id (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) { if (!gToken) {
result.errors.push('Google Ads sin token OAuth'); result.errors.push('Google Ads sin token OAuth');
} else { } else {
for (const customerId of [...new Set(googleIds)]) {
try { try {
const grows = await fetchGoogleCampaignDaily( const grows = await fetchGoogleCampaignDaily(
gads.external_account_id, customerId,
tenant.id, tenant.id,
days, days,
gToken gToken
); );
rows.push(...grows); rows.push(...grows);
platforms.push('google'); result.googleRows += grows.length;
result.googleRows = grows.length;
} catch (e) { } catch (e) {
result.errors.push( result.errors.push(
e instanceof Error ? e.message : 'google sync error' e instanceof Error ? e.message : 'google sync error'
@@ -512,11 +601,32 @@ export async function syncAdsForTenants(opts?: {
} }
} }
} }
}
if (result.googleRows > 0) platforms.push('google');
if (rows.length && platforms.length) { if (rows.length && platforms.length) {
try { try {
await replaceDaily(tenant.id, platforms, rows, days); await replaceDaily(tenant.id, platforms, rows, days);
result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, 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) { } catch (e) {
result.errors.push( result.errors.push(
e instanceof Error ? e.message : 'warehouse write error' e instanceof Error ? e.message : 'warehouse write error'

View File

@@ -1,23 +1,23 @@
/** Agency portfolio helpers — providers, role gates, service-role REST. */ /** Agency portfolio helpers — providers, role gates, service-role REST. */
export const INTEGRATION_PROVIDERS = [ export const INTEGRATION_PROVIDERS = [
{ id: 'meta_ads', label: 'Meta Ads', group: 'paid' }, { id: 'meta_ads', label: 'Meta Ads', group: 'paid', live: true },
{ id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid' }, { id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid', live: true },
{ id: 'ga4', label: 'Google Analytics 4', group: 'analytics' }, { id: 'ga4', label: 'Google Analytics 4', group: 'analytics', live: true },
{ id: 'search_console', label: 'Search Console', group: 'analytics' }, { id: 'search_console', label: 'Search Console', group: 'analytics', live: true },
{ id: 'tag_manager', label: 'Tag Manager', group: 'analytics' }, { id: 'tag_manager', label: 'Tag Manager', group: 'analytics', live: false },
{ id: 'clientify', label: 'Clientify', group: 'crm' }, { id: 'clientify', label: 'Clientify', group: 'crm', live: true },
{ id: 'klaviyo', label: 'Klaviyo', group: 'email' }, { id: 'klaviyo', label: 'Klaviyo', group: 'email', live: false },
{ id: 'mailchimp', label: 'Mailchimp', group: 'email' }, { id: 'mailchimp', label: 'Mailchimp', group: 'email', live: false },
{ id: 'google_drive', label: 'Google Drive', group: 'files' }, { id: 'google_drive', label: 'Google Drive', group: 'files', live: true },
{ id: 'wordpress', label: 'WordPress', group: 'cms' }, { id: 'wordpress', label: 'WordPress', group: 'cms', live: false },
{ id: 'prestashop', label: 'PrestaShop', group: 'cms' }, { id: 'prestashop', label: 'PrestaShop', group: 'cms', live: false },
{ id: 'shopify', label: 'Shopify', group: 'cms' }, { id: 'shopify', label: 'Shopify', group: 'cms', live: false },
{ id: 'instagram', label: 'Instagram', group: 'social' }, { id: 'instagram', label: 'Instagram', group: 'social', live: true },
{ id: 'facebook', label: 'Facebook Page', group: 'social' }, { id: 'facebook', label: 'Facebook Page', group: 'social', live: true },
{ id: 'tiktok', label: 'TikTok', group: 'social' }, { id: 'tiktok', label: 'TikTok', group: 'social', live: false },
{ id: 'linkedin', label: 'LinkedIn', group: 'social' }, { id: 'linkedin', label: 'LinkedIn', group: 'social', live: false },
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops' }, { id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops', live: true },
] as const; ] as const;
export type IntegrationProviderId = 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: { meta: {
property_id: m.ga4.propertyId, property_id: m.ga4.propertyId,
measurement_id: m.ga4.measurementId, 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, error_message: null,
}); });
if (ok) ga4 += 1; if (ok) ga4 += 1;
@@ -231,8 +232,10 @@ export async function applyOrganicMatches(
status: 'connected', status: 'connected',
external_account_id: m.gsc.siteUrl, external_account_id: m.gsc.siteUrl,
label: m.gsc.siteUrl, label: m.gsc.siteUrl,
meta: { site_url: m.gsc.siteUrl }, meta: {
last_sync_at: new Date().toISOString(), site_url: m.gsc.siteUrl,
matched_at: new Date().toISOString(),
},
error_message: null, error_message: null,
} }
); );
@@ -686,7 +689,8 @@ export async function syncAllConnectedOrganic(opts?: {
gscSiteUrl: cfg.gscSiteUrl, gscSiteUrl: cfg.gscSiteUrl,
days, days,
}); });
// bump last_sync on integrations // Solo marcar last_sync_at si hubo filas (no mentir tras match vacío)
if (r.daysWritten > 0) {
await fetch( await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`, `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`,
{ {
@@ -699,6 +703,7 @@ export async function syncAllConnectedOrganic(opts?: {
body: JSON.stringify({ last_sync_at: new Date().toISOString() }), body: JSON.stringify({ last_sync_at: new Date().toISOString() }),
} }
); );
}
results.push({ tenantId, ...r }); results.push({ tenantId, ...r });
} catch (e) { } catch (e) {
results.push({ results.push({

View File

@@ -22,9 +22,29 @@ export function scoreNameToTenant(
if (f === n || f === s) return 100; if (f === n || f === s) return 100;
if (n && (f.includes(n) || n.includes(f))) return 85; if (n && (f.includes(n) || n.includes(f))) return 85;
if (s && (f.includes(s) || s.includes(f))) return 80; 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( 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; if (!ft.size || !nt.size) return 0;
let hit = 0; let hit = 0;

View File

@@ -41,6 +41,10 @@ export type Delta = {
wow: number | null; wow: number | null;
mom: number | null; mom: number | null;
yoy: 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 { function n(v: unknown): number {
@@ -163,6 +167,9 @@ export function buildComparisons(rows: KpiWeeklyRow[]): {
wow: pctChange(currentWeek[k], prevWeek[k]), wow: pctChange(currentWeek[k], prevWeek[k]),
mom: pctChange(currentMonth[k], prevMonth[k]), mom: pctChange(currentMonth[k], prevMonth[k]),
yoy: pctChange(currentMonth[k], currentMonthLastYear[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 { export function formatEur(v: number): string {
return `${v.toLocaleString('es-ES', { return `${v.toLocaleString('es-ES', {
minimumFractionDigits: 0, minimumFractionDigits: 0,
@@ -409,6 +517,8 @@ export type DealWonRow = {
won_at?: string | null; won_at?: string | null;
stage?: string | null; stage?: string | null;
tenant_id?: string | null; tenant_id?: string | null;
/** Optional source/channel when joined from lead or Clientify contact. */
source?: string | null;
}; };
export function filterDealsForRange( export function filterDealsForRange(
@@ -484,6 +594,9 @@ export function mergeDealsIntoWeekly(
export type CrmLeadRow = { export type CrmLeadRow = {
source?: string | null; source?: string | null;
created_at?: 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; status?: string | null;
tenant_id?: string | null; tenant_id?: string | null;
}; };
@@ -523,6 +636,9 @@ export function normalizeLeadStatus(status?: string | null): string {
const s = (status || '').toLowerCase().trim(); const s = (status || '').toLowerCase().trim();
if (!s) return 'sin-estado'; if (!s) return 'sin-estado';
if (s === 'cold-lead') return 'lead frío'; if (s === 'cold-lead') return 'lead frío';
if (s === 'client' || s === 'customer' || s === 'won' || s === 'ganado') {
return 'cliente';
}
return s; return s;
} }
@@ -530,6 +646,46 @@ export function isClienteStatus(status?: string | null): boolean {
return normalizeLeadStatus(status) === 'cliente'; 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 = { export type CrmFunnelTotals = {
total: number; total: number;
cliente: number; cliente: number;
@@ -647,13 +803,27 @@ export function buildCrmChannelStats(leads: CrmLeadRow[]): CrmChannelStats[] {
.sort((a, b) => b.cliente - a.cliente || b.leads - a.leads); .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( export function filterLeadsByDayRange(
rows: CrmLeadRow[], rows: CrmLeadRow[],
since: string, since: string,
until: string until: string,
lens: CrmDateLens = 'created'
): CrmLeadRow[] { ): CrmLeadRow[] {
if (lens === 'snapshot') return rows;
return rows.filter((r) => { 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; return d >= since && d <= until;
}); });
} }
@@ -666,42 +836,72 @@ export type AttributionChannelRow = {
gap: number; gap: number;
cplPlatform: number; cplPlatform: number;
cplCrm: 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). * Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify).
* Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI). * 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( export function attributionCross(
daily: AdDailyRow[], daily: AdDailyRow[],
leads: CrmLeadRow[] leads: CrmLeadRow[],
deals: DealWonRow[] = []
): AttributionChannelRow[] { ): AttributionChannelRow[] {
const map = new Map< const map = new Map<
string, string,
{ spend: number; platformLeads: number; crmLeads: number } {
spend: number;
platformLeads: number;
crmLeads: number;
cierres: number;
ventas: number;
}
>(); >();
for (const r of daily) { const ensure = (channel: string) => {
const channel = (r.platform || 'otros').toLowerCase(); let cur = map.get(channel);
const cur = map.get(channel) || { if (!cur) {
cur = {
spend: 0, spend: 0,
platformLeads: 0, platformLeads: 0,
crmLeads: 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 = ensure(channel);
cur.spend += n(r.spend); cur.spend += n(r.spend);
cur.platformLeads += n(r.platform_leads ?? r.conversions); cur.platformLeads += n(r.platform_leads ?? r.conversions);
map.set(channel, cur);
} }
for (const r of leads) { for (const r of leads) {
const channel = normalizeLeadChannel(r.source); const channel = normalizeLeadChannel(r.source);
const cur = map.get(channel) || { const cur = ensure(channel);
spend: 0,
platformLeads: 0,
crmLeads: 0,
};
cur.crmLeads += 1; 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()] return [...map.entries()]
@@ -713,10 +913,15 @@ export function attributionCross(
gap: v.crmLeads - v.platformLeads, gap: v.crmLeads - v.platformLeads,
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0, cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 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( .sort(
(a, b) => (a, b) =>
b.spend - a.spend || b.spend - a.spend ||
b.ventas - a.ventas ||
b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads) b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads)
); );
} }

View File

@@ -500,23 +500,48 @@ export function matchDocsToTenants(
tenants: { id: string; name: string; slug: string }[], tenants: { id: string; name: string; slug: string }[],
docs: ShDoc[] docs: ShDoc[]
): DocMatchResult[] { ): 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) => ({ const accounts = docs.map((d) => ({
id: d.id, id: d.id,
name: cleanClientDocName(d.name), name: cleanClientDocName(d.name),
})); }));
const matched = matchAccountsToTenants(tenants, accounts, { const matched = matchAccountsToTenants(expanded, accounts, {
threshold: 50, threshold: 50,
ambiguousGap: 8, ambiguousGap: 8,
}); });
return matched.map((m) => ({ // 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 || '', docId: m.accountId || '',
docName: docName:
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '', docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
tenantId: m.status === 'matched' ? m.tenantId : null, tenantId: t.id,
tenantName: m.status === 'matched' ? m.tenantName : null, tenantName: t.name,
score: m.score, score: m.score,
status: m.status, 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). */ /** Best single-doc match for one tenant (manual zoom). */
@@ -524,19 +549,57 @@ export function bestDocForTenant(
tenant: { name: string; slug: string }, tenant: { name: string; slug: string },
docs: ShDoc[] docs: ShDoc[]
): { doc: ShDoc; score: number } | null { ): { doc: ShDoc; score: number } | null {
const names = tenantMatchNames(tenant);
let best: { doc: ShDoc; score: number } | null = null; let best: { doc: ShDoc; score: number } | null = null;
for (const d of docs) { for (const d of docs) {
const score = scoreNameToTenant( const cleaned = cleanClientDocName(d.name);
cleanClientDocName(d.name), let score = 0;
tenant.name, for (const n of names) {
tenant.slug score = Math.max(
score,
scoreNameToTenant(cleaned, n, tenant.slug)
); );
}
if (!best || score > best.score) best = { doc: d, score }; 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; 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 = { type DriveDocHit = {
id: string; id: string;
name: string; name: string;
@@ -603,7 +666,25 @@ export async function findSeguimientoGoogleDoc(
return scoreDriveHits(files2, cleaned); return scoreDriveHits(files2, cleaned);
} }
const files = ((await r.json()) as { files?: DriveDocHit[] }).files || []; 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( function scoreDriveHits(
@@ -613,7 +694,11 @@ function scoreDriveHits(
let best: { file: DriveDocHit; score: number } | null = null; let best: { file: DriveDocHit; score: number } | null = null;
for (const f of files) { for (const f of files) {
const fn = normalizeName(f.name || ''); 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) { for (const h of cleaned) {
const hn = normalizeName(h); const hn = normalizeName(h);
if (!hn) continue; if (!hn) continue;
@@ -624,10 +709,10 @@ function scoreDriveHits(
.split(' ') .split(' ')
.filter( .filter(
(t) => (t) =>
t.length > 3 && t !== 'seguimiento' && t !== 'semanal' t.length > 2 && t !== 'seguimiento' && t !== 'semanal'
); );
const hits = tokens.filter((t) => fn.includes(t)).length; 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); score = Math.max(score, 70 + hits * 5);
} }
} }
@@ -742,20 +827,30 @@ export async function buildSeguimientoRowsForDoc(opts: {
const now = new Date().toISOString(); const now = new Date().toISOString();
const rows: SeguimientoSyncRow[] = []; 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 = [ const nameHints = [
...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name), ...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name),
opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '', ...aliasHints.flatMap((n) => [
opts.tenantName ? `Seguimiento ${opts.tenantName}` : '', `Seguimiento Semanal ${n}`,
`Seguimiento ${n}`,
]),
'Seguimiento BPS',
].filter(Boolean); ].filter(Boolean);
if (exportBodies && nameHints.length) { if (exportBodies && nameHints.length) {
try { try {
const hit = await findSeguimientoGoogleDoc(nameHints); const hit = await findSeguimientoGoogleDoc(nameHints);
if (hit) { if (hit) {
let dayRows: SeguimientoSyncRow[] = [];
try {
const html = await exportGoogleDocHtml(hit.id); const html = await exportGoogleDocHtml(hit.id);
rows.push( dayRows = dayRowsFromHtml({
...dayRowsFromHtml({
tenantId: opts.tenantId, tenantId: opts.tenantId,
html, html,
fileId: hit.id, fileId: hit.id,
@@ -764,8 +859,37 @@ export async function buildSeguimientoRowsForDoc(opts: {
`https://docs.google.com/document/d/${hit.id}/edit`, `https://docs.google.com/document/d/${hit.id}/edit`,
maxEntries, maxEntries,
now, 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 { } catch {
// fall through to canvas pages // fall through to canvas pages
@@ -905,3 +1029,141 @@ export async function patchSuperhumanIntegration(
export function docDisplayHint(name: string): string { export function docDisplayHint(name: string): string {
return normalizeName(cleanClientDocName(name)); 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 url = req.nextUrl.clone();
const path = url.pathname; 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 = const isPublic =
path === '/login' || path === '/login' ||
path === '/api/health' || path === '/api/health' ||
path.startsWith('/api/webhooks/') || path.startsWith('/api/webhooks/') ||
path.startsWith('/api/cron/') ||
path === '/_4v_attr.js' || path === '/_4v_attr.js' ||
path.startsWith('/_next') || path.startsWith('/_next') ||
path === '/favicon.ico' || path === '/favicon.ico' ||