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:
@@ -47,6 +47,28 @@ Nunca pegar keys en PDFs ni `.env.example`.
|
||||
- `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM
|
||||
- OAuth live Meta; Google Ads via env; **GA4/GSC** vía OAuth marketing@ (reconectar Google en Agencia + sync orgánico)
|
||||
- IP allowlist Traefik: mantener IPs oficina actuales en `middleware-hardening.yml`
|
||||
- **`CLIENTIFY_WEBHOOK_SECRET` obligatorio** — sin él el webhook responde 503
|
||||
- **Cron diario (obligatorio en prod)** — ver sección Cron
|
||||
|
||||
## Cron diario (datos siempre frescos para vistas cliente)
|
||||
|
||||
En Dokploy → Environment del `client-portal`:
|
||||
|
||||
1. Define `CRON_SECRET` (string largo aleatorio).
|
||||
2. Schedule diario ~06:00 UTC:
|
||||
|
||||
```bash
|
||||
curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||
"https://app.4ventures.es/api/cron/daily-sync?days=7"
|
||||
```
|
||||
|
||||
Endpoints individuales (mismo secret):
|
||||
|
||||
- `/api/cron/organic-sync` — GA4 + GSC → `organic_daily`
|
||||
- `/api/cron/ads-sync` — Meta + Google Ads → `ad_daily` (API v22)
|
||||
- `/api/cron/superhuman-sync` — Seguimiento / Google Docs
|
||||
|
||||
`last_sync_at` solo se actualiza tras escribir filas reales (no al emparejar).
|
||||
|
||||
## Verificación
|
||||
|
||||
@@ -54,3 +76,4 @@ Nunca pegar keys en PDFs ni `.env.example`.
|
||||
- Rol `client` → redirigido fuera de agencia
|
||||
- Chat/simulate solo con sesión
|
||||
- Health: `serviceRoleConfigured: true`
|
||||
- Tras cron: Orgánico / Ads / Seguimiento con `last_sync_at` de hoy en ficha
|
||||
|
||||
75
scripts/sync_ads_only.ts
Normal file
75
scripts/sync_ads_only.ts
Normal 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
272
scripts/sync_all_data.ts
Normal 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);
|
||||
});
|
||||
316
scripts/sync_clientify_tenant.py
Normal file
316
scripts/sync_clientify_tenant.py
Normal 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())
|
||||
22
sql/leads_status_timing.sql
Normal file
22
sql/leads_status_timing.sql
Normal 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;
|
||||
@@ -20,7 +20,7 @@ export async function POST(req: NextRequest) {
|
||||
const tenantId =
|
||||
typeof body.tenantId === 'string' ? body.tenantId : undefined;
|
||||
const days =
|
||||
typeof body.days === 'number' && body.days > 0 && body.days <= 90
|
||||
typeof body.days === 'number' && body.days > 0 && body.days <= 1000
|
||||
? body.days
|
||||
: 30;
|
||||
|
||||
|
||||
@@ -7,14 +7,8 @@ import {
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import {
|
||||
SUPERHUMAN_PROVIDER,
|
||||
bestDocForTenant,
|
||||
buildSeguimientoRowsForDoc,
|
||||
listDocs,
|
||||
matchDocsToTenants,
|
||||
patchSuperhumanIntegration,
|
||||
syncAllSuperhumanTenants,
|
||||
superhumanConfigured,
|
||||
upsertSeguimientoRows,
|
||||
} from '@/lib/superhuman-docs';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -51,6 +45,9 @@ export async function GET() {
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
|
||||
try {
|
||||
const { listDocs, matchDocsToTenants, SUPERHUMAN_PROVIDER } = await import(
|
||||
'@/lib/superhuman-docs'
|
||||
);
|
||||
const headers = (gate as { headers: { apikey: string; Authorization: string } })
|
||||
.headers;
|
||||
const [docs, tenantsRes] = await Promise.all([
|
||||
@@ -91,8 +88,6 @@ export async function GET() {
|
||||
export async function POST(req: NextRequest) {
|
||||
const gate = await requireAgency();
|
||||
if ('error' in gate && gate.error) return gate.error;
|
||||
const headers = (gate as { headers: { apikey: string; Authorization: string } })
|
||||
.headers;
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const onlyTenant: string | undefined = body.tenantId
|
||||
@@ -102,128 +97,14 @@ export async function POST(req: NextRequest) {
|
||||
const maxEntries = Math.min(Number(body.maxEntries) || 80, 150);
|
||||
|
||||
try {
|
||||
const docs = await listDocs();
|
||||
const tRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) {
|
||||
return NextResponse.json({ error: await tRes.text() }, { status: 500 });
|
||||
}
|
||||
let tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[];
|
||||
if (onlyTenant) {
|
||||
tenants = tenants.filter((t) => t.id === onlyTenant);
|
||||
}
|
||||
|
||||
const results: {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
docId: string | null;
|
||||
docName: string | null;
|
||||
upserted: number;
|
||||
status: string;
|
||||
error?: string;
|
||||
}[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
let docId: string | null = null;
|
||||
let docName: string | null = null;
|
||||
|
||||
// Prefer already-linked integration
|
||||
const integUrl = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${SUPERHUMAN_PROVIDER}&select=external_account_id,meta,label`;
|
||||
const integR = await fetch(integUrl, { headers, cache: 'no-store' });
|
||||
const integRows = integR.ok
|
||||
? ((await integR.json()) as {
|
||||
external_account_id?: string | null;
|
||||
meta?: { doc_name?: string };
|
||||
}[])
|
||||
: [];
|
||||
if (integRows[0]?.external_account_id) {
|
||||
docId = integRows[0].external_account_id;
|
||||
docName =
|
||||
integRows[0].meta?.doc_name ||
|
||||
docs.find((d) => d.id === docId)?.name ||
|
||||
null;
|
||||
} else {
|
||||
const hit = bestDocForTenant(tenant, docs);
|
||||
if (hit) {
|
||||
docId = hit.doc.id;
|
||||
docName = hit.doc.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!docId) {
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'disconnected',
|
||||
error_message: 'Sin doc Superhuman matched por nombre',
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId: null,
|
||||
docName: null,
|
||||
upserted: 0,
|
||||
status: 'unmatched',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await buildSeguimientoRowsForDoc({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
maxEntries,
|
||||
exportBodies,
|
||||
});
|
||||
const upserted = await upsertSeguimientoRows(rows);
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'connected',
|
||||
external_account_id: docId,
|
||||
label: docName || 'Superhuman Docs',
|
||||
last_sync_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
meta: {
|
||||
doc_name: docName,
|
||||
entries: upserted,
|
||||
synced_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
docName,
|
||||
upserted,
|
||||
status: 'synced',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'sync failed';
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'error',
|
||||
external_account_id: docId,
|
||||
error_message: msg.slice(0, 400),
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
docName,
|
||||
upserted: 0,
|
||||
status: 'error',
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await syncAllSuperhumanTenants({
|
||||
tenantId: onlyTenant || null,
|
||||
maxEntries,
|
||||
exportBodies,
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
results,
|
||||
synced: results.filter((r) => r.status === 'synced').length,
|
||||
...result,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -310,7 +310,7 @@ export async function POST(req: NextRequest) {
|
||||
`${i + 1}. **${c.campaign_name}** (${c.platform.toUpperCase()}):\n - Inversión: **${Number(c.spend || 0).toFixed(2)} €**\n - Clics: ${c.clicks || 0} (CTR: ${c.impressions > 0 ? ((c.clicks / c.impressions) * 100).toFixed(2) : 0}%)\n - Leads Captados: **${c.leads || 0}**`
|
||||
).join('\n\n');
|
||||
|
||||
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: Datos diarios procesados vía Windsor.ai.*`);
|
||||
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: ad_daily (Meta + Google Ads sincronizados en el portal).*`);
|
||||
} else {
|
||||
return reply('📢 Actualmente no constan campañas activas con inversión registrada en los últimos 30 días para este portal.');
|
||||
}
|
||||
@@ -347,7 +347,7 @@ export async function POST(req: NextRequest) {
|
||||
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
|
||||
).join('\n\n');
|
||||
|
||||
return reply(`📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Búsqueda híbrida vectorial (pgvector) y BM25 en document_chunks.*`);
|
||||
return reply(`📚 **Fragmentos de la Biblioteca de Activos:**\n\n${excerpts}\n\n*Fuente: document_chunks del tenant (búsqueda en portal).*`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
src/app/api/cron/ads-sync/route.ts
Normal file
43
src/app/api/cron/ads-sync/route.ts
Normal 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);
|
||||
}
|
||||
100
src/app/api/cron/daily-sync/route.ts
Normal file
100
src/app/api/cron/daily-sync/route.ts
Normal 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);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||
import { getAgencyOAuthStatus } from '@/lib/google-drive';
|
||||
|
||||
@@ -6,20 +7,12 @@ export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily (or any schedule) organic sync for all connected tenants.
|
||||
* Daily organic sync for all connected tenants.
|
||||
* Auth: Authorization: Bearer $CRON_SECRET OR ?secret=$CRON_SECRET
|
||||
* Dokploy cron example (daily 06:15 UTC):
|
||||
* curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const secret = process.env.CRON_SECRET?.trim();
|
||||
const auth = req.headers.get('authorization') || '';
|
||||
const q = req.nextUrl.searchParams.get('secret') || '';
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : q;
|
||||
|
||||
if (!secret || token !== secret) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const status = await getAgencyOAuthStatus();
|
||||
if (!status.connected) {
|
||||
@@ -29,7 +22,6 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Rolling 7 days is enough daily; full history already filled by first sync
|
||||
const days = Math.min(
|
||||
28,
|
||||
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
|
||||
|
||||
46
src/app/api/cron/superhuman-sync/route.ts
Normal file
46
src/app/api/cron/superhuman-sync/route.ts
Normal 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);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -18,6 +18,73 @@ type Integ = {
|
||||
meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function cacheSocialSnapshot(
|
||||
tenantId: string,
|
||||
provider: 'facebook' | 'instagram',
|
||||
payload: unknown
|
||||
) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return;
|
||||
const now = new Date().toISOString();
|
||||
// Merge into meta without wiping other keys: read-modify-write
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}&select=meta&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
let meta: Record<string, unknown> = {};
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as { meta?: Record<string, unknown> }[];
|
||||
meta = { ...(rows[0]?.meta || {}) };
|
||||
}
|
||||
// Keep a compact snapshot (metrics + up to 12 posts)
|
||||
const snap =
|
||||
payload && typeof payload === 'object'
|
||||
? {
|
||||
...(payload as object),
|
||||
posts: Array.isArray((payload as { posts?: unknown }).posts)
|
||||
? (payload as { posts: unknown[] }).posts.slice(0, 12)
|
||||
: undefined,
|
||||
}
|
||||
: payload;
|
||||
meta.social_cache = snap;
|
||||
meta.social_cached_at = now;
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
meta,
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readSocialCache(integrations: Integ[]): {
|
||||
facebook?: unknown;
|
||||
instagram?: unknown;
|
||||
at?: string;
|
||||
} {
|
||||
const out: { facebook?: unknown; instagram?: unknown; at?: string } = {};
|
||||
for (const i of integrations) {
|
||||
const cache = i.meta?.social_cache;
|
||||
const at = typeof i.meta?.social_cached_at === 'string'
|
||||
? i.meta.social_cached_at
|
||||
: i.last_sync_at || undefined;
|
||||
if (!cache) continue;
|
||||
if (i.provider === 'facebook') out.facebook = cache;
|
||||
if (i.provider === 'instagram') out.instagram = cache;
|
||||
if (at && (!out.at || at > out.at)) out.at = at;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type SocialPost = {
|
||||
id: string;
|
||||
platform: 'facebook' | 'instagram';
|
||||
@@ -585,25 +652,34 @@ export async function GET(req: NextRequest) {
|
||||
const posts: SocialPost[] = [];
|
||||
const tokens = await resolveMetaAccessTokens();
|
||||
|
||||
const fbInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'facebook' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
const igInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'instagram' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
|
||||
if (!tokens.length) {
|
||||
metrics.warning =
|
||||
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
|
||||
const cached = readSocialCache(integrations);
|
||||
if (cached.facebook) metrics.facebook = cached.facebook;
|
||||
if (cached.instagram) metrics.instagram = cached.instagram;
|
||||
if (cached.facebook || cached.instagram) {
|
||||
metrics.cached = true;
|
||||
metrics.cachedAt = cached.at;
|
||||
metrics.warning +=
|
||||
' Mostrando último snapshot cacheado.';
|
||||
}
|
||||
} else {
|
||||
const fbInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'facebook' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
const igInteg = integrations.find(
|
||||
(i) =>
|
||||
i.provider === 'instagram' &&
|
||||
i.status === 'connected' &&
|
||||
i.external_account_id &&
|
||||
!i.external_account_id.startsWith('http')
|
||||
);
|
||||
|
||||
const token =
|
||||
(await pickWorkingToken(tokens, fbInteg?.external_account_id)) ||
|
||||
(await pickWorkingToken(tokens, igInteg?.external_account_id)) ||
|
||||
@@ -621,6 +697,7 @@ export async function GET(req: NextRequest) {
|
||||
if ('posts' in fb && Array.isArray(fb.posts)) {
|
||||
posts.push(...(fb.posts as SocialPost[]));
|
||||
}
|
||||
await cacheSocialSnapshot(ctx.tenantId, 'facebook', fb);
|
||||
}
|
||||
if (igInteg?.external_account_id) {
|
||||
const ig = await fetchIgBundle(
|
||||
@@ -633,9 +710,18 @@ export async function GET(req: NextRequest) {
|
||||
if ('posts' in ig && Array.isArray(ig.posts)) {
|
||||
posts.push(...(ig.posts as SocialPost[]));
|
||||
}
|
||||
await cacheSocialSnapshot(ctx.tenantId, 'instagram', ig);
|
||||
}
|
||||
} catch (e) {
|
||||
metrics.warning = e instanceof Error ? e.message : 'metrics error';
|
||||
const cached = readSocialCache(integrations);
|
||||
if (cached.facebook && !metrics.facebook) metrics.facebook = cached.facebook;
|
||||
if (cached.instagram && !metrics.instagram)
|
||||
metrics.instagram = cached.instagram;
|
||||
if (cached.facebook || cached.instagram) {
|
||||
metrics.cached = true;
|
||||
metrics.cachedAt = cached.at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,8 +110,13 @@ async function resolveTenantId(slugOrId: string | null): Promise<string | null>
|
||||
const base = supabaseUrl();
|
||||
// UUID?
|
||||
if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId;
|
||||
// Legacy slug aliases (renames)
|
||||
const SLUG_ALIASES: Record<string, string> = {
|
||||
herencias: 'fbm-abogados',
|
||||
};
|
||||
const slug = SLUG_ALIASES[slugOrId.toLowerCase()] || slugOrId;
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slugOrId)}&select=id&limit=1`,
|
||||
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return null;
|
||||
@@ -130,7 +135,59 @@ async function upsertLead(tenantId: string, c: Json) {
|
||||
str(c.channel, 80) ||
|
||||
str(c.medium, 80) ||
|
||||
'clientify';
|
||||
const row = {
|
||||
const newStatus = (str(c.status, 40) || 'new').toLowerCase();
|
||||
const now = new Date().toISOString();
|
||||
const base = supabaseUrl();
|
||||
|
||||
// Preserve alta date across webhook re-writes (DELETE+INSERT).
|
||||
let prevCreated: string | null = null;
|
||||
let prevStatus: string | null = null;
|
||||
let prevStatusChanged: string | null = null;
|
||||
const prevR = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status,status_changed_at&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (prevR.ok) {
|
||||
const rows = (await prevR.json()) as {
|
||||
created_at?: string;
|
||||
status?: string;
|
||||
status_changed_at?: string | null;
|
||||
}[];
|
||||
if (rows[0]) {
|
||||
prevCreated = rows[0].created_at || null;
|
||||
prevStatus = (rows[0].status || '').toLowerCase() || null;
|
||||
prevStatusChanged = rows[0].status_changed_at || null;
|
||||
}
|
||||
} else {
|
||||
// status_changed_at may not exist yet — retry without it
|
||||
const prevR2 = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (prevR2.ok) {
|
||||
const rows = (await prevR2.json()) as {
|
||||
created_at?: string;
|
||||
status?: string;
|
||||
}[];
|
||||
if (rows[0]) {
|
||||
prevCreated = rows[0].created_at || null;
|
||||
prevStatus = (rows[0].status || '').toLowerCase() || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fromCfy =
|
||||
str(c.created, 40) ||
|
||||
str(c.created_at, 40) ||
|
||||
str(c.date_created, 40);
|
||||
let createdAt = prevCreated || fromCfy || now;
|
||||
// Normalize date-only
|
||||
if (createdAt.length === 10) createdAt = `${createdAt}T12:00:00.000Z`;
|
||||
|
||||
const statusChanged =
|
||||
!prevStatus || prevStatus !== newStatus ? now : prevStatusChanged || createdAt;
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
first_name:
|
||||
@@ -142,19 +199,45 @@ async function upsertLead(tenantId: string, c: Json) {
|
||||
medium: str(c.medium, 80),
|
||||
crm: 'clientify',
|
||||
crm_contact_id: cid,
|
||||
status: (str(c.status, 40) || 'new').toLowerCase(),
|
||||
status: newStatus,
|
||||
created_at: createdAt,
|
||||
updated_at: now,
|
||||
status_changed_at: statusChanged,
|
||||
};
|
||||
// Prefer upsert on crm_contact_id if unique; else delete+insert
|
||||
const base = supabaseUrl();
|
||||
|
||||
await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
const ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
|
||||
let ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
...headers,
|
||||
Prefer: 'return=minimal',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
// Schema not migrated yet: drop timing columns and retry
|
||||
if (!ins.ok) {
|
||||
const err = await ins.text();
|
||||
if (err.includes('updated_at') || err.includes('status_changed_at')) {
|
||||
delete row.updated_at;
|
||||
delete row.status_changed_at;
|
||||
ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
Prefer: 'return=minimal',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
} else {
|
||||
throw new Error(`lead insert ${ins.status}: ${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
if (!ins.ok) {
|
||||
throw new Error(`lead insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
|
||||
}
|
||||
@@ -218,7 +301,7 @@ export async function GET(req: NextRequest) {
|
||||
service: 'clientify-webhook',
|
||||
tenant: tenant || null,
|
||||
hint: 'POST events from Clientify. Use ?tenant=<slug>',
|
||||
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia',
|
||||
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=<slug>&secret=…',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,7 +309,17 @@ export async function POST(req: NextRequest) {
|
||||
const secretEnv = process.env.CLIENTIFY_WEBHOOK_SECRET?.trim();
|
||||
const secretQ = req.nextUrl.searchParams.get('secret');
|
||||
const secretH = req.headers.get('x-clientify-secret');
|
||||
if (secretEnv && secretQ !== secretEnv && secretH !== secretEnv) {
|
||||
// Obligatorio: sin secreto el webhook es inyectable desde fuera
|
||||
if (!secretEnv) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'webhook_not_configured',
|
||||
hint: 'Define CLIENTIFY_WEBHOOK_SECRET en Dokploy',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (secretQ !== secretEnv && secretH !== secretEnv) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -238,7 +331,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'missing_or_unknown_tenant',
|
||||
hint: 'Add ?tenant=agua-miia (slug) to the webhook URL',
|
||||
hint: 'Add ?tenant=<slug>&secret=… to the webhook URL',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -260,10 +353,12 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
if (entity === 'contact') {
|
||||
const r = await upsertLead(tenantId, payload);
|
||||
await markClientifyConnected(tenantId, tenantParam);
|
||||
return NextResponse.json({ ok: true, entity, ...r });
|
||||
}
|
||||
if (entity === 'deal') {
|
||||
const r = await upsertDeal(tenantId, payload);
|
||||
await markClientifyConnected(tenantId, tenantParam);
|
||||
return NextResponse.json({ ok: true, entity, ...r });
|
||||
}
|
||||
// company/activity/budget/product: acknowledge for now
|
||||
@@ -279,3 +374,52 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Webhook escribe leads pero la ficha quedaba "disconnected" — alinear status. */
|
||||
async function markClientifyConnected(
|
||||
tenantId: string,
|
||||
slugHint: string | null
|
||||
) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return;
|
||||
const base = supabaseUrl();
|
||||
const now = new Date().toISOString();
|
||||
const body = {
|
||||
status: 'connected',
|
||||
enabled: true,
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
label: 'Clientify',
|
||||
external_account_id: slugHint || undefined,
|
||||
meta: {
|
||||
source: 'webhook',
|
||||
synced_at: now,
|
||||
},
|
||||
};
|
||||
const patch = await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.clientify`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (patch.ok) return;
|
||||
await fetch(`${base}/rest/v1/integrations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=ignore-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: tenantId,
|
||||
provider: 'clientify',
|
||||
...body,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ async function fetchCrmLeads(
|
||||
includeTenantId: boolean
|
||||
): Promise<CrmLeadRow[]> {
|
||||
const select = includeTenantId
|
||||
? 'tenant_id,source,created_at,status,updated_at,status_changed_at'
|
||||
: 'source,created_at,status,updated_at,status_changed_at';
|
||||
const selectFallback = includeTenantId
|
||||
? 'tenant_id,source,created_at,status'
|
||||
: 'source,created_at,status';
|
||||
const pageSize = 1000;
|
||||
@@ -27,12 +30,20 @@ async function fetchCrmLeads(
|
||||
const tidFilter = tenantId
|
||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||
: '';
|
||||
let useSelect = select;
|
||||
for (let offset = 0; offset < 80000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||
`${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) break;
|
||||
if (!r.ok) {
|
||||
if (useSelect !== selectFallback && offset === 0) {
|
||||
useSelect = selectFallback;
|
||||
offset = -pageSize;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const chunk = (await r.json()) as CrmLeadRow[];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
@@ -42,12 +53,24 @@ async function fetchCrmLeads(
|
||||
|
||||
const supabase = await createClient();
|
||||
for (let offset = 0; offset < 80000; offset += pageSize) {
|
||||
const { data } = await supabase
|
||||
let q = supabase
|
||||
.from('leads')
|
||||
.select(select)
|
||||
.eq('crm', 'clientify')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
let { data, error } = await q;
|
||||
if (error && offset === 0) {
|
||||
const retry = await supabase
|
||||
.from('leads')
|
||||
.select(selectFallback)
|
||||
.eq('crm', 'clientify')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
data = retry.data;
|
||||
error = retry.error;
|
||||
}
|
||||
if (error) break;
|
||||
const chunk = (data as CrmLeadRow[]) || [];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
buildComparisons,
|
||||
mergeDealsIntoWeekly,
|
||||
resolveWeeklyRows,
|
||||
resolveWonCohort,
|
||||
sumDaily,
|
||||
sumWeekly,
|
||||
} from '@/lib/metrics';
|
||||
@@ -19,12 +20,13 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Paginate CRM leads (source + created_at) for attribution cross. */
|
||||
/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
|
||||
async function fetchCrmLeads(
|
||||
tenantId: string | null,
|
||||
useServiceRole: boolean
|
||||
): Promise<CrmLeadRow[]> {
|
||||
const select = 'source,created_at';
|
||||
const select = 'source,created_at,status,tenant_id,updated_at,status_changed_at';
|
||||
const selectFallback = 'source,created_at,status,tenant_id';
|
||||
const pageSize = 1000;
|
||||
const out: CrmLeadRow[] = [];
|
||||
|
||||
@@ -35,12 +37,20 @@ async function fetchCrmLeads(
|
||||
const tidFilter = tenantId
|
||||
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||
: '';
|
||||
let useSelect = select;
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||
`${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) break;
|
||||
if (!r.ok) {
|
||||
if (useSelect !== selectFallback && offset === 0) {
|
||||
useSelect = selectFallback;
|
||||
offset = -pageSize;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const chunk = (await r.json()) as CrmLeadRow[];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
@@ -50,12 +60,23 @@ async function fetchCrmLeads(
|
||||
|
||||
const supabase = await createClient();
|
||||
for (let offset = 0; offset < 50000; offset += pageSize) {
|
||||
const { data } = await supabase
|
||||
let { data, error } = await supabase
|
||||
.from('leads')
|
||||
.select(select)
|
||||
.eq('crm', 'clientify')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
if (error && offset === 0) {
|
||||
const retry = await supabase
|
||||
.from('leads')
|
||||
.select(selectFallback)
|
||||
.eq('crm', 'clientify')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1);
|
||||
data = retry.data;
|
||||
error = retry.error;
|
||||
}
|
||||
if (error) break;
|
||||
const chunk = (data as CrmLeadRow[]) || [];
|
||||
out.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
@@ -163,6 +184,9 @@ export default async function KpisPage() {
|
||||
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
||||
}
|
||||
|
||||
const cohort = resolveWonCohort(wonDeals, crmLeads);
|
||||
wonDeals = cohort.deals;
|
||||
|
||||
weekly = mergeDealsIntoWeekly(resolveWeeklyRows(weekly, daily), wonDeals);
|
||||
|
||||
const comparisons = buildComparisons(weekly);
|
||||
@@ -188,6 +212,7 @@ export default async function KpisPage() {
|
||||
dailyRows={daily}
|
||||
crmLeads={crmLeads}
|
||||
wonDeals={wonDeals}
|
||||
cohortSource={cohort.source}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type Integ = {
|
||||
meta?: { folder_name?: string; match_score?: number } | null;
|
||||
};
|
||||
|
||||
type Provider = { id: string; label: string; group: string };
|
||||
type Provider = { id: string; label: string; group: string; live?: boolean };
|
||||
|
||||
const PLACEHOLDERS: Record<string, string> = {
|
||||
meta_ads: 'act_… (Ad Account ID)',
|
||||
@@ -219,6 +219,11 @@ export default function ClientIntegrationsPanel({
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{p.label}
|
||||
{p.live === false && (
|
||||
<span className="ml-2 text-[10px] font-normal text-gray-500">
|
||||
(sin conector API aún)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{folderHint && (
|
||||
<p className="text-[11px] text-brand-700 mt-0.5">
|
||||
|
||||
@@ -299,7 +299,7 @@ export default function CreativesPipeline() {
|
||||
setDraft((d) => ({ ...d, brand_name: e.target.value }))
|
||||
}
|
||||
className={inputCls}
|
||||
placeholder="Agua Miia"
|
||||
placeholder="Marca del cliente"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Canal">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
AdDailyRow,
|
||||
CrmChannelStats,
|
||||
CrmDateLens,
|
||||
CrmLeadRow,
|
||||
attributionCross,
|
||||
buildCrmChannelStats,
|
||||
@@ -32,6 +33,15 @@ function rateClass(rate: number): string {
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
const LENS_HELP: Record<CrmDateLens, string> = {
|
||||
created:
|
||||
'Altas: leads cuya fecha de creación en Clientify cae en el periodo del header.',
|
||||
status:
|
||||
'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at (sql/leads_status_timing.sql).',
|
||||
snapshot:
|
||||
'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora en el warehouse.',
|
||||
};
|
||||
|
||||
export default function CrmDashboard({
|
||||
label,
|
||||
portfolioWide,
|
||||
@@ -44,11 +54,19 @@ export default function CrmDashboard({
|
||||
dailyRows?: AdDailyRow[];
|
||||
}) {
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
const [lens, setLens] = useState<CrmDateLens>('created');
|
||||
|
||||
const hasStatusTiming = useMemo(
|
||||
() => crmLeads.some((l) => Boolean(l.status_changed_at || l.updated_at)),
|
||||
[crmLeads]
|
||||
);
|
||||
|
||||
const rangedLeads = useMemo(() => {
|
||||
if (!crmLeads.length) return [];
|
||||
return filterLeadsByDayRange(crmLeads, since, until);
|
||||
}, [crmLeads, since, until]);
|
||||
const effective: CrmDateLens =
|
||||
lens === 'status' && !hasStatusTiming ? 'created' : lens;
|
||||
return filterLeadsByDayRange(crmLeads, since, until, effective);
|
||||
}, [crmLeads, since, until, lens, hasStatusTiming]);
|
||||
|
||||
const rangedDaily = useMemo(() => {
|
||||
if (!dailyRows.length) return [];
|
||||
@@ -90,6 +108,43 @@ export default function CrmDashboard({
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-600 shrink-0">
|
||||
Lente CRM
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
['created', 'Altas en periodo'],
|
||||
['status', 'Conversiones en periodo'],
|
||||
['snapshot', 'Estado actual'],
|
||||
] as const
|
||||
).map(([id, lab]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setLens(id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
|
||||
lens === id
|
||||
? 'bg-brand-500 text-white border-brand-500'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-brand-300'
|
||||
}`}
|
||||
>
|
||||
{lab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 -mt-4">{LENS_HELP[lens]}</p>
|
||||
{lens === 'status' && !hasStatusTiming ? (
|
||||
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||||
Aún no hay <code className="font-mono">status_changed_at</code> en
|
||||
warehouse — aplicando filtro por alta. Ejecuta{' '}
|
||||
<code className="font-mono">sql/leads_status_timing.sql</code> en
|
||||
Supabase y deja que los webhooks actualicen contactos.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!crmLeads.length ? (
|
||||
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3">
|
||||
Sin leads Clientify en este contexto. Importa el histórico o activa el
|
||||
@@ -97,7 +152,6 @@ export default function CrmDashboard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* North star */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
{[
|
||||
{
|
||||
@@ -118,12 +172,12 @@ export default function CrmDashboard({
|
||||
{
|
||||
label: 'En oportunidad',
|
||||
value: formatNum(funnel.enOportunidad),
|
||||
icon: <Briefcase className="w-4 h-4 text-sky-400" />,
|
||||
icon: <Briefcase className="w-4 h-4 text-violet-400" />,
|
||||
},
|
||||
{
|
||||
label: 'Lead frío*',
|
||||
label: 'Lead frío',
|
||||
value: formatNum(funnel.leadFrio),
|
||||
icon: <Flame className="w-4 h-4 text-amber-400" />,
|
||||
icon: <Flame className="w-4 h-4 text-sky-400" />,
|
||||
},
|
||||
{
|
||||
label: 'Lead perdido',
|
||||
@@ -133,65 +187,96 @@ export default function CrmDashboard({
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between text-gray-500 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-wider">{s.label}</p>
|
||||
<div className="flex items-center justify-between text-gray-600 mb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">
|
||||
{s.label}
|
||||
</span>
|
||||
{s.icon}
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900">{s.value}</p>
|
||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
||||
{s.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-600 -mt-5">
|
||||
*Frío incluye templado / caliente / cold-lead.
|
||||
</p>
|
||||
|
||||
{/* % cliente por canal — paid first */}
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
% a cliente por canal
|
||||
</h3>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
|
||||
(ganado). Paid arriba; resto de atribución abajo.
|
||||
(ganado). Ads spend del periodo es referencia.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{channelRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-6 text-center">
|
||||
Sin canales en el periodo.
|
||||
{!channelRows.length ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
Sin leads en esta lente/periodo.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto space-y-6">
|
||||
<ChannelTable
|
||||
title="Paid (Meta / Google)"
|
||||
rows={paidChannels}
|
||||
spendByChannel={spendByChannel}
|
||||
/>
|
||||
<ChannelTable
|
||||
title="Resto de canales CRM"
|
||||
rows={otherChannels}
|
||||
spendByChannel={spendByChannel}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-gray-600 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 pr-3 font-medium">Clientes</th>
|
||||
<th className="py-2 pr-3 font-medium">% a cliente</th>
|
||||
<th className="py-2 font-medium">Inversión ads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...paidChannels, ...otherChannels].map((row) => (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 font-semibold capitalize text-gray-800">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
|
||||
{formatNum(row.leads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
|
||||
{formatNum(row.cliente)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
|
||||
>
|
||||
{row.clienteRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-2.5 tabular-nums text-gray-700">
|
||||
{spendByChannel.get(row.channel)
|
||||
? formatEur(spendByChannel.get(row.channel)!)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-500">
|
||||
La inversión por canal (si hay ads) es solo referencia; el % a cliente
|
||||
es CRM. Lente activa: {lens}.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Funnel statuses */}
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Funnel por estado</h3>
|
||||
{statusRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-4 text-center">Sin datos.</p>
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Desglose de status</h3>
|
||||
{!statusRows.length ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">Sin datos.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-200">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-gray-600 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Estado</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 font-medium">% del total</th>
|
||||
<th className="py-2 pr-3 font-medium">Status</th>
|
||||
<th className="py-2 pr-3 font-medium">N</th>
|
||||
<th className="py-2 font-medium">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -200,13 +285,11 @@ export default function CrmDashboard({
|
||||
key={row.status}
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-gray-800 capitalize">
|
||||
{row.status}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
<td className="py-2.5 pr-3 text-gray-800">{row.status}</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{formatNum(row.count)}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-800">
|
||||
<td className="py-2.5 tabular-nums text-gray-600">
|
||||
{row.pct.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
@@ -216,91 +299,6 @@ export default function CrmDashboard({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="text-[11px] text-gray-600">
|
||||
La inversión por canal (si hay ads) es solo referencia; el % a cliente es
|
||||
100% CRM (Clientify).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelTable({
|
||||
title,
|
||||
rows,
|
||||
spendByChannel,
|
||||
}: {
|
||||
title: string;
|
||||
rows: CrmChannelStats[];
|
||||
spendByChannel: Map<string, number>;
|
||||
}) {
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">Sin filas.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 pr-3 font-medium">Clientes</th>
|
||||
<th className="py-2 pr-3 font-medium">% a cliente</th>
|
||||
<th className="py-2 pr-3 font-medium">En oportunidad</th>
|
||||
<th className="py-2 pr-3 font-medium">Frío*</th>
|
||||
<th className="py-2 pr-3 font-medium">Perdido</th>
|
||||
<th className="py-2 font-medium">Spend ads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const spend = spendByChannel.get(row.channel) || 0;
|
||||
return (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-brand-700 font-semibold capitalize">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
{formatNum(row.leads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-emerald-300 font-medium">
|
||||
{formatNum(row.cliente)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
|
||||
>
|
||||
{row.clienteRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
{formatNum(row.enOportunidad)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
{formatNum(row.leadFrio)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
{formatNum(row.leadPerdido)}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-400">
|
||||
{spend ? formatEur(spend) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
isoYesterday,
|
||||
} from '@/lib/ad-tracker-range';
|
||||
|
||||
const STORAGE_KEY = '4v_global_date_range_v1';
|
||||
const STORAGE_KEY = '4v_global_date_range_v2';
|
||||
|
||||
type Stored = {
|
||||
preset: RangePreset;
|
||||
@@ -38,24 +38,24 @@ const DateRangeContext = createContext<Ctx | null>(null);
|
||||
function loadStored(): Stored {
|
||||
const y = isoYesterday();
|
||||
if (typeof window === 'undefined') {
|
||||
return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
if (!raw) return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
const j = JSON.parse(raw) as Stored;
|
||||
return {
|
||||
preset: j.preset || 'last_7d',
|
||||
preset: j.preset || 'last_30d',
|
||||
customSince: j.customSince || y,
|
||||
customUntil: j.customUntil || y,
|
||||
};
|
||||
} catch {
|
||||
return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
}
|
||||
}
|
||||
|
||||
export function DateRangeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [preset, setPresetState] = useState<RangePreset>('last_7d');
|
||||
const [preset, setPresetState] = useState<RangePreset>('last_30d');
|
||||
const [customSince, setCustomSince] = useState('');
|
||||
const [customUntil, setCustomUntil] = useState('');
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
formatNum,
|
||||
formatPct,
|
||||
attributionCross,
|
||||
buildRangeDeltas,
|
||||
filterDailyByDayRange,
|
||||
filterDealsForRange,
|
||||
filterLeadsByDayRange,
|
||||
@@ -50,22 +51,31 @@ function DeltaTriple({
|
||||
delta,
|
||||
invert,
|
||||
empty,
|
||||
format,
|
||||
current,
|
||||
}: {
|
||||
delta: Delta;
|
||||
invert?: boolean;
|
||||
empty?: boolean;
|
||||
format: (v: number) => string;
|
||||
current: number;
|
||||
}) {
|
||||
if (empty) {
|
||||
return (
|
||||
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center">
|
||||
Sin cierres en el periodo — sincroniza deals Clientify
|
||||
Sin cierres en este rango — amplía el periodo o revisa deals ganados en
|
||||
Clientify
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const cells: { label: string; v: number | null }[] = [
|
||||
{ label: 'WoW', v: delta.wow },
|
||||
{ label: 'MoM', v: delta.mom },
|
||||
{ label: 'YoY', v: delta.yoy },
|
||||
const cells: {
|
||||
label: string;
|
||||
v: number | null;
|
||||
base: number | null;
|
||||
}[] = [
|
||||
{ label: 'WoW', v: delta.wow, base: delta.wowBase },
|
||||
{ label: 'MoM', v: delta.mom, base: delta.momBase },
|
||||
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase },
|
||||
];
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||
@@ -85,6 +95,9 @@ function DeltaTriple({
|
||||
: good
|
||||
? ArrowUpRight
|
||||
: ArrowDownRight;
|
||||
const hasBase = c.base !== null && !(c.base === 0 && c.v === null);
|
||||
const absDelta =
|
||||
hasBase && c.base !== null ? current - c.base : null;
|
||||
return (
|
||||
<div
|
||||
key={c.label}
|
||||
@@ -99,6 +112,27 @@ function DeltaTriple({
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{formatPct(c.v)}
|
||||
</p>
|
||||
{hasBase ? (
|
||||
<>
|
||||
<p className="text-[11px] font-semibold text-gray-800 mt-1 tabular-nums leading-tight">
|
||||
prev {format(c.base ?? 0)}
|
||||
</p>
|
||||
{absDelta !== null && (
|
||||
<p
|
||||
className={`text-[10px] font-medium tabular-nums leading-tight ${color}`}
|
||||
>
|
||||
Δ{' '}
|
||||
{absDelta > 0
|
||||
? `+${format(absDelta)}`
|
||||
: format(absDelta)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[10px] text-gray-500 mt-1 leading-tight">
|
||||
sin histórico
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -127,6 +161,7 @@ export default function MetricsDashboard({
|
||||
dailyRows = [],
|
||||
crmLeads = [],
|
||||
wonDeals = [],
|
||||
cohortSource = 'deals',
|
||||
}: {
|
||||
label: string;
|
||||
portfolioWide: boolean;
|
||||
@@ -145,6 +180,7 @@ export default function MetricsDashboard({
|
||||
dailyRows?: AdDailyRow[];
|
||||
crmLeads?: CrmLeadRow[];
|
||||
wonDeals?: DealWonRow[];
|
||||
cohortSource?: 'deals' | 'cliente_leads' | 'none';
|
||||
}) {
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
|
||||
@@ -178,9 +214,15 @@ export default function MetricsDashboard({
|
||||
return withCohortFromDeals(base, rangedDeals);
|
||||
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
|
||||
|
||||
/** Comparatives relative to the selected range (not “today”). */
|
||||
const rangeDeltas = useMemo(
|
||||
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
|
||||
[dailyRows, wonDeals, since, until]
|
||||
);
|
||||
|
||||
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||||
if (rangedDaily.length || rangedLeads.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads);
|
||||
if (rangedDaily.length || rangedLeads.length || rangedDeals.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads, rangedDeals);
|
||||
}
|
||||
const map = new Map<string, { spend: number; leads: number }>();
|
||||
for (const p of channelPoints) {
|
||||
@@ -200,14 +242,42 @@ export default function MetricsDashboard({
|
||||
gap: -v.leads,
|
||||
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
|
||||
cplCrm: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
roas: 0,
|
||||
cac: 0,
|
||||
}))
|
||||
.sort((a, b) => b.spend - a.spend);
|
||||
}, [rangedDaily, rangedLeads, channelPoints, since, until]);
|
||||
}, [rangedDaily, rangedLeads, rangedDeals, channelPoints, since, until]);
|
||||
|
||||
const attributionTotals = useMemo(() => {
|
||||
return attributionRows.reduce(
|
||||
(a, r) => ({
|
||||
spend: a.spend + r.spend,
|
||||
platformLeads: a.platformLeads + r.platformLeads,
|
||||
crmLeads: a.crmLeads + r.crmLeads,
|
||||
gap: a.gap + r.gap,
|
||||
cierres: a.cierres + r.cierres,
|
||||
ventas: a.ventas + r.ventas,
|
||||
}),
|
||||
{
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
gap: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
}
|
||||
);
|
||||
}, [attributionRows]);
|
||||
|
||||
const crmPeriodTotal = rangedLeads.length;
|
||||
const hasCrm = crmLeads.length > 0;
|
||||
const hasCohort = rangedDeals.length > 0;
|
||||
const cierres = periodTotals.deals;
|
||||
const cohortFromLeads = cohortSource === 'cliente_leads';
|
||||
const revenueMissing =
|
||||
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
|
||||
|
||||
const cards: Card[] = [
|
||||
{
|
||||
@@ -236,8 +306,10 @@ export default function MetricsDashboard({
|
||||
key: 'revenue',
|
||||
label: 'Ventas (CRM)',
|
||||
hint: hasCohort
|
||||
? `${formatNum(cierres)} deals ganados · importe Clientify`
|
||||
: 'Deals won Clientify en el periodo',
|
||||
? cohortFromLeads
|
||||
? `${formatNum(cierres)} clientes Clientify · importe no en webhook`
|
||||
: `${formatNum(cierres)} deals ganados · importe Clientify`
|
||||
: 'Deals won / status cliente Clientify en el periodo',
|
||||
format: formatEur,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||
cohort: true,
|
||||
@@ -246,7 +318,7 @@ export default function MetricsDashboard({
|
||||
key: 'cac',
|
||||
label: 'CAC',
|
||||
hint: hasCohort
|
||||
? `Inversión ads ÷ ${formatNum(cierres)} ventas`
|
||||
? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}`
|
||||
: 'Inversión ÷ deals ganados (no leads)',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <Target className="w-4 h-4 text-amber-600" />,
|
||||
@@ -256,7 +328,9 @@ export default function MetricsDashboard({
|
||||
{
|
||||
key: 'roas',
|
||||
label: 'ROAS ventas',
|
||||
hint: 'Ventas CRM ÷ inversión ads',
|
||||
hint: revenueMissing
|
||||
? 'Falta importe en CRM (API BPS) para ROAS'
|
||||
: `Importe deals ganados Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`,
|
||||
format: (v) => `${formatNum(v)}x`,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
||||
cohort: true,
|
||||
@@ -287,7 +361,11 @@ export default function MetricsDashboard({
|
||||
const worstGap = [...attributionRows].sort(
|
||||
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
|
||||
)[0];
|
||||
return { top, worstGap };
|
||||
const platTotal = attributionRows.reduce((a, r) => a + r.platformLeads, 0);
|
||||
const crmTotal = attributionRows.reduce((a, r) => a + r.crmLeads, 0);
|
||||
const crmPaid = paid.reduce((a, r) => a + r.crmLeads, 0);
|
||||
const crmOther = crmTotal - crmPaid;
|
||||
return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther };
|
||||
}, [attributionRows]);
|
||||
|
||||
return (
|
||||
@@ -307,8 +385,20 @@ export default function MetricsDashboard({
|
||||
|
||||
{!hasCohort && (
|
||||
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
|
||||
Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con
|
||||
fecha de cierre en este periodo. El gasto de ads no cuenta como venta.
|
||||
Ventas / CAC / ROAS vacíos en <strong>{rangeHint}</strong>: no hay
|
||||
deals ganados ni contactos con status «cliente» en este rango
|
||||
{wonDeals.length
|
||||
? ` (sí hay ${formatNum(wonDeals.length)} en el histórico — prueba «30 días» o «Mes»).`
|
||||
: '.'}{' '}
|
||||
El gasto de ads no cuenta como venta.
|
||||
</p>
|
||||
)}
|
||||
{cohortFromLeads && hasCohort && (
|
||||
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||||
Cohorte desde contactos Clientify status «cliente» (no hay deals
|
||||
ganados sincronizados). Importe € incompleto hasta conectar la API
|
||||
Clientify de este tenant. Meta pixel reporta compras en el periodo;
|
||||
el CRM marca clientes sin amount en el webhook.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -350,7 +440,13 @@ export default function MetricsDashboard({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{cards.map((c) => {
|
||||
const emptyCohort = Boolean(c.cohort) && !hasCohort;
|
||||
const value = emptyCohort ? '—' : c.format(periodTotals[c.key]);
|
||||
const showClientesCount =
|
||||
c.key === 'revenue' && revenueMissing && !emptyCohort;
|
||||
const value = emptyCohort
|
||||
? '—'
|
||||
: showClientesCount
|
||||
? `${formatNum(cierres)} clientes`
|
||||
: c.format(periodTotals[c.key]);
|
||||
return (
|
||||
<div
|
||||
key={c.key}
|
||||
@@ -367,9 +463,11 @@ export default function MetricsDashboard({
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
|
||||
<DeltaTriple
|
||||
delta={deltas[c.key]}
|
||||
delta={rangeDeltas[c.key] ?? deltas[c.key]}
|
||||
invert={c.invert}
|
||||
empty={emptyCohort}
|
||||
format={c.format}
|
||||
current={periodTotals[c.key]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -383,16 +481,9 @@ export default function MetricsDashboard({
|
||||
Cruce por canal · ads vs CRM
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Gasto y leads de plataforma vs leads CRM (como en{' '}
|
||||
<a
|
||||
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand-600 underline"
|
||||
>
|
||||
combinación de datos
|
||||
</a>
|
||||
): el coste vive en ads; el resultado de negocio en CRM/GA.
|
||||
Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las
|
||||
ventas de deals sin fuente de contacto aparecen en{' '}
|
||||
<span className="font-medium">sin-atribuir</span>.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
||||
@@ -401,16 +492,17 @@ export default function MetricsDashboard({
|
||||
</div>
|
||||
{channelInsight && (
|
||||
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
|
||||
<strong>Resumen:</strong> más inversión en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.top.channel}
|
||||
</span>{' '}
|
||||
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '}
|
||||
<strong>Resumen:</strong> plat {formatNum(channelInsight.platTotal)}{' '}
|
||||
vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '}
|
||||
{channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''}
|
||||
{formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De
|
||||
los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto
|
||||
ads y {formatNum(channelInsight.crmOther)} en otras fuentes
|
||||
(integrations, ecommerce, messenger…). Mayor desfase en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.worstGap.channel}
|
||||
</span>{' '}
|
||||
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
|
||||
{formatNum(channelInsight.worstGap.gap)}).
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{attributionRows.length === 0 ? (
|
||||
@@ -427,6 +519,9 @@ export default function MetricsDashboard({
|
||||
<th className="py-2 pr-3 font-semibold">Leads plat</th>
|
||||
<th className="py-2 pr-3 font-semibold">Leads CRM</th>
|
||||
<th className="py-2 pr-3 font-semibold">Gap</th>
|
||||
<th className="py-2 pr-3 font-semibold">Cierres</th>
|
||||
<th className="py-2 pr-3 font-semibold">Ventas</th>
|
||||
<th className="py-2 pr-3 font-semibold">ROAS</th>
|
||||
<th className="py-2 pr-3 font-semibold">CPL plat</th>
|
||||
<th className="py-2 font-semibold">CPL CRM</th>
|
||||
</tr>
|
||||
@@ -469,6 +564,15 @@ export default function MetricsDashboard({
|
||||
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{hasCrm ? formatNum(row.cierres) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
|
||||
{row.ventas > 0 ? formatEur(row.ventas) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{row.cplPlatform
|
||||
? `${formatNum(row.cplPlatform)} €`
|
||||
@@ -482,6 +586,70 @@ export default function MetricsDashboard({
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{attributionRows.length > 0 && (
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="py-2.5 pr-3 text-gray-900">Total</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||||
{attributionTotals.spend
|
||||
? formatEur(attributionTotals.spend)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{formatNum(attributionTotals.platformLeads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 tabular-nums ${
|
||||
hasCrm
|
||||
? gapClass(attributionTotals.gap)
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasCrm
|
||||
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{hasCrm ? formatNum(attributionTotals.cierres) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||||
{attributionTotals.ventas > 0 || periodTotals.revenue > 0
|
||||
? formatEur(
|
||||
attributionTotals.ventas || periodTotals.revenue
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{attributionTotals.spend > 0 &&
|
||||
(attributionTotals.ventas || periodTotals.revenue) > 0
|
||||
? `${formatNum(
|
||||
(attributionTotals.ventas ||
|
||||
periodTotals.revenue) / attributionTotals.spend
|
||||
)}x`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{attributionTotals.platformLeads > 0
|
||||
? `${formatNum(
|
||||
attributionTotals.spend /
|
||||
attributionTotals.platformLeads
|
||||
)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 tabular-nums">
|
||||
{hasCrm && attributionTotals.crmLeads > 0
|
||||
? `${formatNum(
|
||||
attributionTotals.spend /
|
||||
attributionTotals.crmLeads
|
||||
)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
@@ -498,13 +666,13 @@ export default function MetricsDashboard({
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`s-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
className="flex-1 min-w-0 h-full flex items-end"
|
||||
title={`${d.date}: ${formatEur(d.spend)}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`,
|
||||
height: `${Math.max(4, (d.spend / maxSpend) * 80)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -523,13 +691,18 @@ export default function MetricsDashboard({
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`l-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
className="flex-1 min-w-0 h-full flex items-end"
|
||||
title={`${d.date}: ${formatNum(d.leads)} leads`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.leads / Math.max(1, ...dailyChart.map((x) => x.leads))) * 100)}%`,
|
||||
height: `${Math.max(
|
||||
4,
|
||||
(d.leads /
|
||||
Math.max(1, ...dailyChart.map((x) => x.leads))) *
|
||||
80
|
||||
)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/lib/match';
|
||||
|
||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v18';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
export const META_OAUTH_PROVIDER = 'meta_ads';
|
||||
|
||||
export function metaEnvConfigured(): boolean {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||
|
||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v21';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
|
||||
export type SyncTenantResult = {
|
||||
tenantId: string;
|
||||
@@ -20,8 +20,25 @@ type IntegrationRow = {
|
||||
provider: string;
|
||||
external_account_id: string | null;
|
||||
status?: string;
|
||||
meta?: {
|
||||
extra_ad_accounts?: { account_id?: string; external_account_id?: string }[];
|
||||
alternate_accounts?: { external_account_id?: string }[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
function extraAccountIds(row: IntegrationRow | undefined): string[] {
|
||||
if (!row) return [];
|
||||
const out: string[] = [];
|
||||
for (const x of row.meta?.extra_ad_accounts || []) {
|
||||
const id = x.account_id || x.external_account_id;
|
||||
if (id) out.push(id);
|
||||
}
|
||||
for (const x of row.meta?.alternate_accounts || []) {
|
||||
if (x.external_account_id) out.push(x.external_account_id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type TenantRow = { id: string; name: string };
|
||||
|
||||
type AdDailyInsert = {
|
||||
@@ -114,22 +131,39 @@ function gadsHeaders(accessToken: string, loginCustomerId?: string) {
|
||||
return h;
|
||||
}
|
||||
|
||||
async function fetchMetaAdDaily(
|
||||
accountId: string,
|
||||
tenantId: string,
|
||||
days: number
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const token = await resolveMetaAccessToken();
|
||||
if (!token) throw new Error('Sin token Meta');
|
||||
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||
const since = new Date();
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const until = new Date();
|
||||
const timeRange = JSON.stringify({
|
||||
since: since.toISOString().slice(0, 10),
|
||||
until: until.toISOString().slice(0, 10),
|
||||
});
|
||||
function isoDayUTC(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Inclusive day windows of at most `chunkDays` from `since` → today. */
|
||||
function dayWindows(days: number, chunkDays: number): { since: string; until: string }[] {
|
||||
const until = new Date();
|
||||
const start = new Date();
|
||||
start.setUTCDate(start.getUTCDate() - days);
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
until.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const out: { since: string; until: string }[] = [];
|
||||
let cursor = new Date(start);
|
||||
while (cursor <= until) {
|
||||
const end = new Date(cursor);
|
||||
end.setUTCDate(end.getUTCDate() + chunkDays - 1);
|
||||
if (end > until) end.setTime(until.getTime());
|
||||
out.push({ since: isoDayUTC(cursor), until: isoDayUTC(end) });
|
||||
cursor = new Date(end);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchMetaInsightsWindow(
|
||||
act: string,
|
||||
token: string,
|
||||
tenantId: string,
|
||||
since: string,
|
||||
until: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const timeRange = JSON.stringify({ since, until });
|
||||
const out: AdDailyInsert[] = [];
|
||||
let url: string | null =
|
||||
`${META_GRAPH}/${act}/insights` +
|
||||
@@ -177,19 +211,32 @@ async function fetchMetaAdDaily(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleCampaignDaily(
|
||||
customerId: string,
|
||||
async function fetchMetaAdDaily(
|
||||
accountId: string,
|
||||
tenantId: string,
|
||||
days: number,
|
||||
days: number
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const token = await resolveMetaAccessToken();
|
||||
if (!token) throw new Error('Sin token Meta');
|
||||
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||
// Meta rejects huge ad-level ranges — pull in ~30d windows
|
||||
const windows = dayWindows(days, 30);
|
||||
const out: AdDailyInsert[] = [];
|
||||
for (const w of windows) {
|
||||
out.push(
|
||||
...(await fetchMetaInsightsWindow(act, token, tenantId, w.since, w.until))
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleWindow(
|
||||
cid: string,
|
||||
tenantId: string,
|
||||
sinceStr: string,
|
||||
untilStr: string,
|
||||
accessToken: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const cid = customerId.replace(/-/g, '');
|
||||
const since = new Date();
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const until = new Date();
|
||||
const sinceStr = since.toISOString().slice(0, 10);
|
||||
const untilStr = until.toISOString().slice(0, 10);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
campaign.id,
|
||||
@@ -304,6 +351,23 @@ async function fetchGoogleCampaignDaily(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleCampaignDaily(
|
||||
customerId: string,
|
||||
tenantId: string,
|
||||
days: number,
|
||||
accessToken: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const cid = customerId.replace(/-/g, '');
|
||||
const windows = dayWindows(days, 90);
|
||||
const out: AdDailyInsert[] = [];
|
||||
for (const w of windows) {
|
||||
out.push(
|
||||
...(await fetchGoogleWindow(cid, tenantId, w.since, w.until, accessToken))
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function replaceDaily(
|
||||
tenantId: string,
|
||||
platforms: string[],
|
||||
@@ -350,17 +414,23 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const sinceStr = since.toISOString().slice(0, 10);
|
||||
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const daily = (await r.json()) as {
|
||||
const daily: {
|
||||
date?: string;
|
||||
platform?: string;
|
||||
spend?: number;
|
||||
platform_leads?: number;
|
||||
}[];
|
||||
}[] = [];
|
||||
const pageSize = 1000;
|
||||
for (let offset = 0; offset < 200000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads&order=date.asc&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const chunk = (await r.json()) as typeof daily;
|
||||
daily.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
|
||||
type Agg = { spend: number; leads: number };
|
||||
const map = new Map<string, Agg>();
|
||||
@@ -401,27 +471,33 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
|
||||
|
||||
if (payload.length) {
|
||||
// kpi_weekly may be a materialized view — best-effort only
|
||||
const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
const msg = await ins.text();
|
||||
if (msg.includes('materialized view')) {
|
||||
// UI falls back to ad_daily; skip hard failure
|
||||
return 0;
|
||||
for (let i = 0; i < payload.length; i += 400) {
|
||||
const chunk = payload.slice(i, i + 400);
|
||||
const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
const msg = await ins.text();
|
||||
if (msg.includes('materialized view')) {
|
||||
// UI falls back to ad_daily; skip hard failure
|
||||
return 0;
|
||||
}
|
||||
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
|
||||
}
|
||||
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
|
||||
}
|
||||
}
|
||||
return payload.length;
|
||||
}
|
||||
|
||||
/** Max lookback for intentional backfills (≈ Jan 2024 → now). Cron stays short. */
|
||||
export const ADS_BACKFILL_MAX_DAYS = 1000;
|
||||
|
||||
export async function syncAdsForTenants(opts?: {
|
||||
tenantId?: string;
|
||||
days?: number;
|
||||
@@ -429,20 +505,29 @@ export async function syncAdsForTenants(opts?: {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const base = supabaseUrl();
|
||||
const days = opts?.days ?? 30;
|
||||
const days = Math.min(
|
||||
ADS_BACKFILL_MAX_DAYS,
|
||||
Math.max(1, opts?.days ?? 30)
|
||||
);
|
||||
|
||||
const tRes = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name&order=name.asc`,
|
||||
`${base}/rest/v1/tenants?select=id,name&agency_followed=neq.false&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) throw new Error(await tRes.text());
|
||||
let tenants = (await tRes.json()) as TenantRow[];
|
||||
if (opts?.tenantId) {
|
||||
tenants = tenants.filter((t) => t.id === opts.tenantId);
|
||||
// Explicit id may be archived satellite — still allow
|
||||
const all = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const allTenants = all.ok ? ((await all.json()) as TenantRow[]) : tenants;
|
||||
tenants = allTenants.filter((t) => t.id === opts.tenantId);
|
||||
}
|
||||
|
||||
const iRes = await fetch(
|
||||
`${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status&external_account_id=not.is.null`,
|
||||
`${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status,meta&external_account_id=not.is.null`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!iRes.ok) throw new Error(await iRes.text());
|
||||
@@ -471,52 +556,77 @@ export async function syncAdsForTenants(opts?: {
|
||||
const platforms: string[] = [];
|
||||
|
||||
const meta = mine.find((i) => i.provider === 'meta_ads' && i.external_account_id);
|
||||
if (meta?.external_account_id) {
|
||||
const metaIds = [
|
||||
...(meta?.external_account_id ? [meta.external_account_id] : []),
|
||||
...extraAccountIds(meta),
|
||||
];
|
||||
for (const accountId of [...new Set(metaIds)]) {
|
||||
try {
|
||||
const mrows = await fetchMetaAdDaily(
|
||||
meta.external_account_id,
|
||||
tenant.id,
|
||||
days
|
||||
);
|
||||
const mrows = await fetchMetaAdDaily(accountId, tenant.id, days);
|
||||
rows.push(...mrows);
|
||||
platforms.push('meta');
|
||||
result.metaRows = mrows.length;
|
||||
result.metaRows += mrows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'meta sync error'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result.metaRows > 0) platforms.push('meta');
|
||||
|
||||
const gads = mine.find(
|
||||
(i) => i.provider === 'google_ads' && i.external_account_id
|
||||
);
|
||||
if (gads?.external_account_id) {
|
||||
const googleIds = [
|
||||
...(gads?.external_account_id ? [gads.external_account_id] : []),
|
||||
...extraAccountIds(gads),
|
||||
];
|
||||
if (googleIds.length) {
|
||||
if (!gToken) {
|
||||
result.errors.push('Google Ads sin token OAuth');
|
||||
} else {
|
||||
try {
|
||||
const grows = await fetchGoogleCampaignDaily(
|
||||
gads.external_account_id,
|
||||
tenant.id,
|
||||
days,
|
||||
gToken
|
||||
);
|
||||
rows.push(...grows);
|
||||
platforms.push('google');
|
||||
result.googleRows = grows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'google sync error'
|
||||
);
|
||||
for (const customerId of [...new Set(googleIds)]) {
|
||||
try {
|
||||
const grows = await fetchGoogleCampaignDaily(
|
||||
customerId,
|
||||
tenant.id,
|
||||
days,
|
||||
gToken
|
||||
);
|
||||
rows.push(...grows);
|
||||
result.googleRows += grows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'google sync error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.googleRows > 0) platforms.push('google');
|
||||
|
||||
if (rows.length && platforms.length) {
|
||||
try {
|
||||
await replaceDaily(tenant.id, platforms, rows, days);
|
||||
result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days);
|
||||
const now = new Date().toISOString();
|
||||
for (const p of platforms) {
|
||||
const provider = p === 'meta' ? 'meta_ads' : 'google_ads';
|
||||
await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${provider}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'warehouse write error'
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
/** Agency portfolio helpers — providers, role gates, service-role REST. */
|
||||
|
||||
export const INTEGRATION_PROVIDERS = [
|
||||
{ id: 'meta_ads', label: 'Meta Ads', group: 'paid' },
|
||||
{ id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid' },
|
||||
{ id: 'ga4', label: 'Google Analytics 4', group: 'analytics' },
|
||||
{ id: 'search_console', label: 'Search Console', group: 'analytics' },
|
||||
{ id: 'tag_manager', label: 'Tag Manager', group: 'analytics' },
|
||||
{ id: 'clientify', label: 'Clientify', group: 'crm' },
|
||||
{ id: 'klaviyo', label: 'Klaviyo', group: 'email' },
|
||||
{ id: 'mailchimp', label: 'Mailchimp', group: 'email' },
|
||||
{ id: 'google_drive', label: 'Google Drive', group: 'files' },
|
||||
{ id: 'wordpress', label: 'WordPress', group: 'cms' },
|
||||
{ id: 'prestashop', label: 'PrestaShop', group: 'cms' },
|
||||
{ id: 'shopify', label: 'Shopify', group: 'cms' },
|
||||
{ id: 'instagram', label: 'Instagram', group: 'social' },
|
||||
{ id: 'facebook', label: 'Facebook Page', group: 'social' },
|
||||
{ id: 'tiktok', label: 'TikTok', group: 'social' },
|
||||
{ id: 'linkedin', label: 'LinkedIn', group: 'social' },
|
||||
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops' },
|
||||
{ id: 'meta_ads', label: 'Meta Ads', group: 'paid', live: true },
|
||||
{ id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid', live: true },
|
||||
{ id: 'ga4', label: 'Google Analytics 4', group: 'analytics', live: true },
|
||||
{ id: 'search_console', label: 'Search Console', group: 'analytics', live: true },
|
||||
{ id: 'tag_manager', label: 'Tag Manager', group: 'analytics', live: false },
|
||||
{ id: 'clientify', label: 'Clientify', group: 'crm', live: true },
|
||||
{ id: 'klaviyo', label: 'Klaviyo', group: 'email', live: false },
|
||||
{ id: 'mailchimp', label: 'Mailchimp', group: 'email', live: false },
|
||||
{ id: 'google_drive', label: 'Google Drive', group: 'files', live: true },
|
||||
{ id: 'wordpress', label: 'WordPress', group: 'cms', live: false },
|
||||
{ id: 'prestashop', label: 'PrestaShop', group: 'cms', live: false },
|
||||
{ id: 'shopify', label: 'Shopify', group: 'cms', live: false },
|
||||
{ id: 'instagram', label: 'Instagram', group: 'social', live: true },
|
||||
{ id: 'facebook', label: 'Facebook Page', group: 'social', live: true },
|
||||
{ id: 'tiktok', label: 'TikTok', group: 'social', live: false },
|
||||
{ id: 'linkedin', label: 'LinkedIn', group: 'social', live: false },
|
||||
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops', live: true },
|
||||
] as const;
|
||||
|
||||
export type IntegrationProviderId =
|
||||
|
||||
26
src/lib/cron-auth.ts
Normal file
26
src/lib/cron-auth.ts
Normal 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;
|
||||
}
|
||||
@@ -215,8 +215,9 @@ export async function applyOrganicMatches(
|
||||
meta: {
|
||||
property_id: m.ga4.propertyId,
|
||||
measurement_id: m.ga4.measurementId,
|
||||
matched_at: new Date().toISOString(),
|
||||
},
|
||||
last_sync_at: new Date().toISOString(),
|
||||
// last_sync_at solo tras volcar organic_daily (no al emparejar)
|
||||
error_message: null,
|
||||
});
|
||||
if (ok) ga4 += 1;
|
||||
@@ -231,8 +232,10 @@ export async function applyOrganicMatches(
|
||||
status: 'connected',
|
||||
external_account_id: m.gsc.siteUrl,
|
||||
label: m.gsc.siteUrl,
|
||||
meta: { site_url: m.gsc.siteUrl },
|
||||
last_sync_at: new Date().toISOString(),
|
||||
meta: {
|
||||
site_url: m.gsc.siteUrl,
|
||||
matched_at: new Date().toISOString(),
|
||||
},
|
||||
error_message: null,
|
||||
}
|
||||
);
|
||||
@@ -686,19 +689,21 @@ export async function syncAllConnectedOrganic(opts?: {
|
||||
gscSiteUrl: cfg.gscSiteUrl,
|
||||
days,
|
||||
});
|
||||
// bump last_sync on integrations
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ last_sync_at: new Date().toISOString() }),
|
||||
}
|
||||
);
|
||||
// Solo marcar last_sync_at si hubo filas (no mentir tras match vacío)
|
||||
if (r.daysWritten > 0) {
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ last_sync_at: new Date().toISOString() }),
|
||||
}
|
||||
);
|
||||
}
|
||||
results.push({ tenantId, ...r });
|
||||
} catch (e) {
|
||||
results.push({
|
||||
|
||||
@@ -22,9 +22,29 @@ export function scoreNameToTenant(
|
||||
if (f === n || f === s) return 100;
|
||||
if (n && (f.includes(n) || n.includes(f))) return 85;
|
||||
if (s && (f.includes(s) || s.includes(f))) return 80;
|
||||
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
||||
// Ignore generic tokens that cause false clinic/school matches
|
||||
const WEAK = new Set([
|
||||
'clinica',
|
||||
'clinic',
|
||||
'dental',
|
||||
'school',
|
||||
'app',
|
||||
'the',
|
||||
'and',
|
||||
'de',
|
||||
'la',
|
||||
'el',
|
||||
'los',
|
||||
'las',
|
||||
'del',
|
||||
]);
|
||||
const ft = new Set(
|
||||
f.split(' ').filter((t) => t.length > 2 && !WEAK.has(t))
|
||||
);
|
||||
const nt = new Set(
|
||||
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
||||
[...n.split(' '), ...s.split(' ')].filter(
|
||||
(t) => t.length > 2 && !WEAK.has(t)
|
||||
)
|
||||
);
|
||||
if (!ft.size || !nt.size) return 0;
|
||||
let hit = 0;
|
||||
|
||||
@@ -41,6 +41,10 @@ export type Delta = {
|
||||
wow: number | null;
|
||||
mom: number | null;
|
||||
yoy: number | null;
|
||||
/** Absolute value of the comparison baseline (previous period). */
|
||||
wowBase: number | null;
|
||||
momBase: number | null;
|
||||
yoyBase: number | null;
|
||||
};
|
||||
|
||||
function n(v: unknown): number {
|
||||
@@ -163,6 +167,9 @@ export function buildComparisons(rows: KpiWeeklyRow[]): {
|
||||
wow: pctChange(currentWeek[k], prevWeek[k]),
|
||||
mom: pctChange(currentMonth[k], prevMonth[k]),
|
||||
yoy: pctChange(currentMonth[k], currentMonthLastYear[k]),
|
||||
wowBase: prevWeek[k],
|
||||
momBase: prevMonth[k],
|
||||
yoyBase: currentMonthLastYear[k],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,6 +183,107 @@ export function buildComparisons(rows: KpiWeeklyRow[]): {
|
||||
};
|
||||
}
|
||||
|
||||
function shiftIsoDays(iso: string, deltaDays: number): string {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + deltaDays);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
function inclusiveDaySpan(since: string, until: string): number {
|
||||
const a = new Date(`${since}T00:00:00Z`).getTime();
|
||||
const b = new Date(`${until}T00:00:00Z`).getTime();
|
||||
return Math.max(1, Math.round((b - a) / 86400000) + 1);
|
||||
}
|
||||
|
||||
function shiftYear(iso: string, years: number): string {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
d.setUTCFullYear(d.getUTCFullYear() + years);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
/** Totals for an inclusive day window from daily ads + won deals. */
|
||||
export function totalsForDayRange(
|
||||
daily: AdDailyRow[],
|
||||
deals: DealWonRow[],
|
||||
since: string,
|
||||
until: string
|
||||
): MetricTotals {
|
||||
const base = metricTotalsFromDaily(filterDailyByDayRange(daily, since, until));
|
||||
return withCohortFromDeals(base, filterDealsForRange(deals, since, until));
|
||||
}
|
||||
|
||||
/**
|
||||
* WoW / MoM / YoY relative to the selected date range (not “today”).
|
||||
* - WoW: previous window of the same length
|
||||
* - MoM: previous calendar month if the range is a full month; else prior equal-length window
|
||||
* - YoY: same calendar dates one year earlier
|
||||
*/
|
||||
export function buildRangeDeltas(
|
||||
daily: AdDailyRow[],
|
||||
deals: DealWonRow[],
|
||||
since: string,
|
||||
until: string
|
||||
): Record<keyof MetricTotals, Delta> {
|
||||
const span = inclusiveDaySpan(since, until);
|
||||
const cur = totalsForDayRange(daily, deals, since, until);
|
||||
|
||||
const wowUntil = shiftIsoDays(since, -1);
|
||||
const wowSince = shiftIsoDays(wowUntil, -(span - 1));
|
||||
const wow = totalsForDayRange(daily, deals, wowSince, wowUntil);
|
||||
|
||||
const sinceD = new Date(`${since}T00:00:00Z`);
|
||||
const untilD = new Date(`${until}T00:00:00Z`);
|
||||
const isFullMonth =
|
||||
sinceD.getUTCDate() === 1 &&
|
||||
untilD.getUTCDate() ===
|
||||
new Date(
|
||||
Date.UTC(untilD.getUTCFullYear(), untilD.getUTCMonth() + 1, 0)
|
||||
).getUTCDate() &&
|
||||
sinceD.getUTCMonth() === untilD.getUTCMonth() &&
|
||||
sinceD.getUTCFullYear() === untilD.getUTCFullYear();
|
||||
|
||||
let momSince: string;
|
||||
let momUntil: string;
|
||||
if (isFullMonth) {
|
||||
const prevMonthEnd = shiftIsoDays(since, -1);
|
||||
const prevMonthStart = new Date(
|
||||
Date.UTC(sinceD.getUTCFullYear(), sinceD.getUTCMonth() - 1, 1)
|
||||
);
|
||||
momSince = isoDate(prevMonthStart);
|
||||
momUntil = prevMonthEnd;
|
||||
} else {
|
||||
momUntil = shiftIsoDays(since, -1);
|
||||
momSince = shiftIsoDays(momUntil, -(span - 1));
|
||||
}
|
||||
const mom = totalsForDayRange(daily, deals, momSince, momUntil);
|
||||
|
||||
const yoySince = shiftYear(since, -1);
|
||||
const yoyUntil = shiftYear(until, -1);
|
||||
const yoy = totalsForDayRange(daily, deals, yoySince, yoyUntil);
|
||||
|
||||
const keys: (keyof MetricTotals)[] = [
|
||||
'spend',
|
||||
'leads',
|
||||
'deals',
|
||||
'revenue',
|
||||
'cac',
|
||||
'roas',
|
||||
'cpl',
|
||||
];
|
||||
const deltas = {} as Record<keyof MetricTotals, Delta>;
|
||||
for (const k of keys) {
|
||||
deltas[k] = {
|
||||
wow: pctChange(cur[k], wow[k]),
|
||||
mom: pctChange(cur[k], mom[k]),
|
||||
yoy: pctChange(cur[k], yoy[k]),
|
||||
wowBase: wow[k],
|
||||
momBase: mom[k],
|
||||
yoyBase: yoy[k],
|
||||
};
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
export function formatEur(v: number): string {
|
||||
return `${v.toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 0,
|
||||
@@ -409,6 +517,8 @@ export type DealWonRow = {
|
||||
won_at?: string | null;
|
||||
stage?: string | null;
|
||||
tenant_id?: string | null;
|
||||
/** Optional source/channel when joined from lead or Clientify contact. */
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
export function filterDealsForRange(
|
||||
@@ -484,6 +594,9 @@ export function mergeDealsIntoWeekly(
|
||||
export type CrmLeadRow = {
|
||||
source?: string | null;
|
||||
created_at?: string | null;
|
||||
/** Last status change (→ cliente). Prefer for “ventas en periodo”. */
|
||||
status_changed_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
status?: string | null;
|
||||
tenant_id?: string | null;
|
||||
};
|
||||
@@ -523,6 +636,9 @@ export function normalizeLeadStatus(status?: string | null): string {
|
||||
const s = (status || '').toLowerCase().trim();
|
||||
if (!s) return 'sin-estado';
|
||||
if (s === 'cold-lead') return 'lead frío';
|
||||
if (s === 'client' || s === 'customer' || s === 'won' || s === 'ganado') {
|
||||
return 'cliente';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -530,6 +646,46 @@ export function isClienteStatus(status?: string | null): boolean {
|
||||
return normalizeLeadStatus(status) === 'cliente';
|
||||
}
|
||||
|
||||
/**
|
||||
* When Clientify won-deals are missing (webhook without deals / no API key),
|
||||
* treat contacts marked as cliente as cohort closes for Ventas / CAC / ROAS.
|
||||
* Amount stays 0 unless the lead carries one — revenue may be incomplete.
|
||||
*/
|
||||
export function clienteLeadsAsWonDeals(leads: CrmLeadRow[]): DealWonRow[] {
|
||||
const out: DealWonRow[] = [];
|
||||
for (const l of leads) {
|
||||
if (!isClienteStatus(l.status)) continue;
|
||||
const wonAt = (
|
||||
l.status_changed_at ||
|
||||
l.updated_at ||
|
||||
l.created_at ||
|
||||
''
|
||||
).trim();
|
||||
if (!wonAt) continue;
|
||||
out.push({
|
||||
stage: 'won',
|
||||
amount: 0,
|
||||
won_at: wonAt,
|
||||
tenant_id: l.tenant_id || null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer explicit won deals; if none, fall back to Clientify «cliente» leads.
|
||||
*/
|
||||
export function resolveWonCohort(
|
||||
deals: DealWonRow[],
|
||||
leads: CrmLeadRow[]
|
||||
): { deals: DealWonRow[]; source: 'deals' | 'cliente_leads' | 'none' } {
|
||||
const won = deals.filter((d) => !d.stage || d.stage === 'won');
|
||||
if (won.length) return { deals: won, source: 'deals' };
|
||||
const fromLeads = clienteLeadsAsWonDeals(leads);
|
||||
if (fromLeads.length) return { deals: fromLeads, source: 'cliente_leads' };
|
||||
return { deals: [], source: 'none' };
|
||||
}
|
||||
|
||||
export type CrmFunnelTotals = {
|
||||
total: number;
|
||||
cliente: number;
|
||||
@@ -647,13 +803,27 @@ export function buildCrmChannelStats(leads: CrmLeadRow[]): CrmChannelStats[] {
|
||||
.sort((a, b) => b.cliente - a.cliente || b.leads - a.leads);
|
||||
}
|
||||
|
||||
export type CrmDateLens = 'created' | 'status' | 'snapshot';
|
||||
|
||||
/**
|
||||
* Filter CRM leads by date.
|
||||
* - created: alta del lead (default histórico)
|
||||
* - status: cuándo cambió el status (ventas/clientes en el periodo) — needs status_changed_at
|
||||
* - snapshot: ignore dates (estado actual del CRM)
|
||||
*/
|
||||
export function filterLeadsByDayRange(
|
||||
rows: CrmLeadRow[],
|
||||
since: string,
|
||||
until: string
|
||||
until: string,
|
||||
lens: CrmDateLens = 'created'
|
||||
): CrmLeadRow[] {
|
||||
if (lens === 'snapshot') return rows;
|
||||
return rows.filter((r) => {
|
||||
const d = (r.created_at || '').slice(0, 10);
|
||||
const raw =
|
||||
lens === 'status'
|
||||
? r.status_changed_at || r.updated_at || r.created_at
|
||||
: r.created_at;
|
||||
const d = (raw || '').slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
});
|
||||
}
|
||||
@@ -666,42 +836,72 @@ export type AttributionChannelRow = {
|
||||
gap: number;
|
||||
cplPlatform: number;
|
||||
cplCrm: number;
|
||||
/** Contacts marked cliente (by CRM source) in the period. */
|
||||
cierres: number;
|
||||
/** Won-deal revenue attributed to channel (or sin-atribuir). */
|
||||
ventas: number;
|
||||
/** Ventas ÷ inversión (0 if no spend). */
|
||||
roas: number;
|
||||
/** Inversión ÷ cierres (0 if no cierres). */
|
||||
cac: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify).
|
||||
* Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI).
|
||||
* Ventas come from won deals; without deal.source they land in `sin-atribuir`.
|
||||
*/
|
||||
export function attributionCross(
|
||||
daily: AdDailyRow[],
|
||||
leads: CrmLeadRow[]
|
||||
leads: CrmLeadRow[],
|
||||
deals: DealWonRow[] = []
|
||||
): AttributionChannelRow[] {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ spend: number; platformLeads: number; crmLeads: number }
|
||||
{
|
||||
spend: number;
|
||||
platformLeads: number;
|
||||
crmLeads: number;
|
||||
cierres: number;
|
||||
ventas: number;
|
||||
}
|
||||
>();
|
||||
|
||||
const ensure = (channel: string) => {
|
||||
let cur = map.get(channel);
|
||||
if (!cur) {
|
||||
cur = {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
};
|
||||
map.set(channel, cur);
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
for (const r of daily) {
|
||||
const channel = (r.platform || 'otros').toLowerCase();
|
||||
const cur = map.get(channel) || {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
};
|
||||
const cur = ensure(channel);
|
||||
cur.spend += n(r.spend);
|
||||
cur.platformLeads += n(r.platform_leads ?? r.conversions);
|
||||
map.set(channel, cur);
|
||||
}
|
||||
|
||||
for (const r of leads) {
|
||||
const channel = normalizeLeadChannel(r.source);
|
||||
const cur = map.get(channel) || {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
};
|
||||
const cur = ensure(channel);
|
||||
cur.crmLeads += 1;
|
||||
map.set(channel, cur);
|
||||
if (isClienteStatus(r.status)) cur.cierres += 1;
|
||||
}
|
||||
|
||||
for (const d of deals) {
|
||||
if (d.stage && d.stage !== 'won') continue;
|
||||
const raw = (d.source || '').trim();
|
||||
const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir';
|
||||
const cur = ensure(channel);
|
||||
cur.ventas += n(d.amount);
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
@@ -713,10 +913,15 @@ export function attributionCross(
|
||||
gap: v.crmLeads - v.platformLeads,
|
||||
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
|
||||
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
|
||||
cierres: v.cierres,
|
||||
ventas: v.ventas,
|
||||
roas: v.spend > 0 ? v.ventas / v.spend : 0,
|
||||
cac: v.cierres > 0 ? v.spend / v.cierres : 0,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.spend - a.spend ||
|
||||
b.ventas - a.ventas ||
|
||||
b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -500,23 +500,48 @@ export function matchDocsToTenants(
|
||||
tenants: { id: string; name: string; slug: string }[],
|
||||
docs: ShDoc[]
|
||||
): DocMatchResult[] {
|
||||
// Expand short brand names so BPS ↔ BLUE PARROT SCHOOL matches
|
||||
const expanded = tenants.flatMap((t) => {
|
||||
const names = tenantMatchNames(t);
|
||||
return names.map((name) => ({ ...t, name }));
|
||||
});
|
||||
const accounts = docs.map((d) => ({
|
||||
id: d.id,
|
||||
name: cleanClientDocName(d.name),
|
||||
}));
|
||||
const matched = matchAccountsToTenants(tenants, accounts, {
|
||||
const matched = matchAccountsToTenants(expanded, accounts, {
|
||||
threshold: 50,
|
||||
ambiguousGap: 8,
|
||||
});
|
||||
return matched.map((m) => ({
|
||||
docId: m.accountId || '',
|
||||
docName:
|
||||
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
|
||||
tenantId: m.status === 'matched' ? m.tenantId : null,
|
||||
tenantName: m.status === 'matched' ? m.tenantName : null,
|
||||
score: m.score,
|
||||
status: m.status,
|
||||
}));
|
||||
// Deduplicate by original tenant id (aliases create duplicate rows)
|
||||
const byTenant = new Map<string, (typeof matched)[number]>();
|
||||
for (const m of matched) {
|
||||
if (m.status !== 'matched') continue;
|
||||
const prev = byTenant.get(m.tenantId);
|
||||
if (!prev || m.score > prev.score) byTenant.set(m.tenantId, m);
|
||||
}
|
||||
return tenants.map((t) => {
|
||||
const m = byTenant.get(t.id);
|
||||
if (m) {
|
||||
return {
|
||||
docId: m.accountId || '',
|
||||
docName:
|
||||
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
score: m.score,
|
||||
status: 'matched' as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
docId: '',
|
||||
docName: '',
|
||||
tenantId: null,
|
||||
tenantName: null,
|
||||
score: 0,
|
||||
status: 'unmatched' as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Best single-doc match for one tenant (manual zoom). */
|
||||
@@ -524,19 +549,57 @@ export function bestDocForTenant(
|
||||
tenant: { name: string; slug: string },
|
||||
docs: ShDoc[]
|
||||
): { doc: ShDoc; score: number } | null {
|
||||
const names = tenantMatchNames(tenant);
|
||||
let best: { doc: ShDoc; score: number } | null = null;
|
||||
for (const d of docs) {
|
||||
const score = scoreNameToTenant(
|
||||
cleanClientDocName(d.name),
|
||||
tenant.name,
|
||||
tenant.slug
|
||||
);
|
||||
const cleaned = cleanClientDocName(d.name);
|
||||
let score = 0;
|
||||
for (const n of names) {
|
||||
score = Math.max(
|
||||
score,
|
||||
scoreNameToTenant(cleaned, n, tenant.slug)
|
||||
);
|
||||
}
|
||||
if (!best || score > best.score) best = { doc: d, score };
|
||||
}
|
||||
if (!best || best.score < 50) return null;
|
||||
if (!best || best.score < 75) return null;
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Extra names so short brands match full Superhuman / Drive titles. */
|
||||
const TENANT_DOC_ALIASES: Record<string, string[]> = {
|
||||
bps: ['Blue Parrot School', 'Blue Parrot', 'BPS'],
|
||||
'agua-miia': ['Agua Miia', 'Aguamiia', 'Agua'],
|
||||
'brava-dermoestetica': ['Brava', 'Brava Dermoestética'],
|
||||
andala: ['Andala'],
|
||||
'loybee-app': ['LoyBee', 'Loybee', 'LoyBee App'],
|
||||
noberplast: ['Noberplast'],
|
||||
'virginia-molet': ['Virginia Molet', 'Virginia'],
|
||||
'4ventures-hub': ['4Ventures', '4VENTURES'],
|
||||
'delgado-abogados': ['Delgado Abogados', 'Delgado'],
|
||||
'equinsa-parking': ['Equinsa', 'Equinsa Parking'],
|
||||
'consigue-tus-papeles': ['Consigue Tus Papeles', 'CTP'],
|
||||
'talento-y-deporte': ['Talento y Deporte', 'Talento'],
|
||||
'la-freseria': ['La Fresería', 'Fresería', 'Freseria'],
|
||||
gravitae: ['Gravitae'],
|
||||
menttum: ['Menttum'],
|
||||
'wine-advisors': ['Wine Advisors', 'Wine'],
|
||||
ecp: ['ECP'],
|
||||
mcd: ['MCD'],
|
||||
'fbm-abogados': ['FBM Abogados', 'FBM', 'Herencias'],
|
||||
'clinica-sonrisas': ['Clínica Dental Sonrisas', 'Sonrisas', 'Clinica Sonrisas'],
|
||||
'sumar-inversion': ['Sumar Inversión', 'Sumar'],
|
||||
};
|
||||
|
||||
function tenantMatchNames(tenant: { name: string; slug: string }): string[] {
|
||||
const out = [tenant.name];
|
||||
const aliases = TENANT_DOC_ALIASES[tenant.slug.toLowerCase()] || [];
|
||||
for (const a of aliases) {
|
||||
if (!out.some((x) => normalizeName(x) === normalizeName(a))) out.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type DriveDocHit = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -603,7 +666,25 @@ export async function findSeguimientoGoogleDoc(
|
||||
return scoreDriveHits(files2, cleaned);
|
||||
}
|
||||
const files = ((await r.json()) as { files?: DriveDocHit[] }).files || [];
|
||||
return scoreDriveHits(files, cleaned);
|
||||
const scored = scoreDriveHits(files, cleaned);
|
||||
if (scored) return scored;
|
||||
|
||||
// Also search «Seguimiento …» without «Semanal» (e.g. «Seguimiento BPS»)
|
||||
const p2 = new URLSearchParams({
|
||||
q: "name contains 'Seguimiento' and mimeType='application/vnd.google-apps.document' and trashed=false",
|
||||
fields: 'files(id,name,webViewLink,modifiedTime)',
|
||||
pageSize: '50',
|
||||
corpora: 'allDrives',
|
||||
includeItemsFromAllDrives: 'true',
|
||||
supportsAllDrives: 'true',
|
||||
});
|
||||
const r2 = await fetch(`https://www.googleapis.com/drive/v3/files?${p2}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r2.ok) return null;
|
||||
const files2 = ((await r2.json()) as { files?: DriveDocHit[] }).files || [];
|
||||
return scoreDriveHits(files2, cleaned);
|
||||
}
|
||||
|
||||
function scoreDriveHits(
|
||||
@@ -613,7 +694,11 @@ function scoreDriveHits(
|
||||
let best: { file: DriveDocHit; score: number } | null = null;
|
||||
for (const f of files) {
|
||||
const fn = normalizeName(f.name || '');
|
||||
let score = /seguimiento\s+semanal/.test(fn) ? 20 : 0;
|
||||
let score = /seguimiento\s+semanal/.test(fn)
|
||||
? 20
|
||||
: /\bseguimiento\b/.test(fn)
|
||||
? 10
|
||||
: 0;
|
||||
for (const h of cleaned) {
|
||||
const hn = normalizeName(h);
|
||||
if (!hn) continue;
|
||||
@@ -624,10 +709,10 @@ function scoreDriveHits(
|
||||
.split(' ')
|
||||
.filter(
|
||||
(t) =>
|
||||
t.length > 3 && t !== 'seguimiento' && t !== 'semanal'
|
||||
t.length > 2 && t !== 'seguimiento' && t !== 'semanal'
|
||||
);
|
||||
const hits = tokens.filter((t) => fn.includes(t)).length;
|
||||
if (tokens.length && hits / tokens.length >= 0.6) {
|
||||
if (tokens.length && hits / tokens.length >= 0.5) {
|
||||
score = Math.max(score, 70 + hits * 5);
|
||||
}
|
||||
}
|
||||
@@ -742,20 +827,30 @@ export async function buildSeguimientoRowsForDoc(opts: {
|
||||
const now = new Date().toISOString();
|
||||
const rows: SeguimientoSyncRow[] = [];
|
||||
|
||||
// 1) Google Doc «Seguimiento Semanal …» — HTML completo partido por fecha
|
||||
// 1) Google Doc «Seguimiento Semanal …» / «Seguimiento {cliente}» — HTML por día
|
||||
const slugGuess = /\bbps\b|parrot/i.test(opts.tenantName || '')
|
||||
? 'bps'
|
||||
: (opts.tenantName || '').toLowerCase().replace(/\s+/g, '-');
|
||||
const aliasHints = opts.tenantName
|
||||
? tenantMatchNames({ name: opts.tenantName, slug: slugGuess })
|
||||
: ['BPS', 'Blue Parrot School'];
|
||||
const nameHints = [
|
||||
...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name),
|
||||
opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '',
|
||||
opts.tenantName ? `Seguimiento ${opts.tenantName}` : '',
|
||||
...aliasHints.flatMap((n) => [
|
||||
`Seguimiento Semanal ${n}`,
|
||||
`Seguimiento ${n}`,
|
||||
]),
|
||||
'Seguimiento BPS',
|
||||
].filter(Boolean);
|
||||
|
||||
if (exportBodies && nameHints.length) {
|
||||
try {
|
||||
const hit = await findSeguimientoGoogleDoc(nameHints);
|
||||
if (hit) {
|
||||
const html = await exportGoogleDocHtml(hit.id);
|
||||
rows.push(
|
||||
...dayRowsFromHtml({
|
||||
let dayRows: SeguimientoSyncRow[] = [];
|
||||
try {
|
||||
const html = await exportGoogleDocHtml(hit.id);
|
||||
dayRows = dayRowsFromHtml({
|
||||
tenantId: opts.tenantId,
|
||||
html,
|
||||
fileId: hit.id,
|
||||
@@ -764,8 +859,37 @@ export async function buildSeguimientoRowsForDoc(opts: {
|
||||
`https://docs.google.com/document/d/${hit.id}/edit`,
|
||||
maxEntries,
|
||||
now,
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
// Docs grandes: HTML export 403 «too large» → plain text
|
||||
const plain = await exportGoogleDocPlain(hit.id);
|
||||
const sections = splitNotesByDay(plain).slice(0, maxEntries);
|
||||
dayRows = sections.map((sec) => ({
|
||||
tenant_id: opts.tenantId,
|
||||
source: 'superhuman' as const,
|
||||
entry_type: 'meeting' as const,
|
||||
external_id: `gdoc:${hit.id}:${sec.dateIso}`,
|
||||
title: `Seguimiento · ${sec.label}`,
|
||||
summary:
|
||||
sec.body
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
|
||||
.find((l) => l.length > 8)
|
||||
?.slice(0, 220) || null,
|
||||
body_md: `<pre>${sec.body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</pre>`,
|
||||
browser_link:
|
||||
hit.webViewLink ||
|
||||
`https://docs.google.com/document/d/${hit.id}/edit`,
|
||||
metrics: parseMetricsFromMarkdown(sec.body),
|
||||
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
|
||||
synced_at: now,
|
||||
updated_at: now,
|
||||
}));
|
||||
}
|
||||
rows.push(...dayRows);
|
||||
}
|
||||
} catch {
|
||||
// fall through to canvas pages
|
||||
@@ -905,3 +1029,141 @@ export async function patchSuperhumanIntegration(
|
||||
export function docDisplayHint(name: string): string {
|
||||
return normalizeName(cleanClientDocName(name));
|
||||
}
|
||||
|
||||
export type SuperhumanSyncResult = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
docId: string | null;
|
||||
docName: string | null;
|
||||
upserted: number;
|
||||
status: 'synced' | 'unmatched' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Sync seguimiento for all (or one) tenants with Superhuman/Drive notes. */
|
||||
export async function syncAllSuperhumanTenants(opts?: {
|
||||
tenantId?: string | null;
|
||||
maxEntries?: number;
|
||||
exportBodies?: boolean;
|
||||
}): Promise<{ synced: number; results: SuperhumanSyncResult[] }> {
|
||||
if (!superhumanConfigured()) {
|
||||
throw new Error('Falta SUPERHUMAN_DOCS_API_KEY');
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
|
||||
const docs = await listDocs();
|
||||
const tRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) throw new Error(await tRes.text());
|
||||
let tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[];
|
||||
if (opts?.tenantId) {
|
||||
tenants = tenants.filter((t) => t.id === opts.tenantId);
|
||||
}
|
||||
|
||||
const maxEntries = Math.min(opts?.maxEntries ?? 100, 150);
|
||||
const exportBodies = opts?.exportBodies !== false;
|
||||
const results: SuperhumanSyncResult[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
let docId: string | null = null;
|
||||
let docName: string | null = null;
|
||||
|
||||
const integUrl = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${SUPERHUMAN_PROVIDER}&select=external_account_id,meta,label`;
|
||||
const integR = await fetch(integUrl, { headers, cache: 'no-store' });
|
||||
const integRows = integR.ok
|
||||
? ((await integR.json()) as {
|
||||
external_account_id?: string | null;
|
||||
meta?: { doc_name?: string };
|
||||
}[])
|
||||
: [];
|
||||
if (integRows[0]?.external_account_id) {
|
||||
docId = integRows[0].external_account_id;
|
||||
docName =
|
||||
integRows[0].meta?.doc_name ||
|
||||
docs.find((d) => d.id === docId)?.name ||
|
||||
null;
|
||||
} else {
|
||||
const hit = bestDocForTenant(tenant, docs);
|
||||
if (hit) {
|
||||
docId = hit.doc.id;
|
||||
docName = hit.doc.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!docId) {
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'disconnected',
|
||||
error_message: 'Sin doc Superhuman matched por nombre',
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId: null,
|
||||
docName: null,
|
||||
upserted: 0,
|
||||
status: 'unmatched',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await buildSeguimientoRowsForDoc({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
maxEntries,
|
||||
exportBodies,
|
||||
});
|
||||
const upserted = await upsertSeguimientoRows(rows);
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'connected',
|
||||
external_account_id: docId,
|
||||
label: docName || 'Superhuman Docs',
|
||||
last_sync_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
meta: {
|
||||
doc_name: docName,
|
||||
entries: upserted,
|
||||
synced_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
docName,
|
||||
upserted,
|
||||
status: 'synced',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'sync failed';
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'error',
|
||||
external_account_id: docId,
|
||||
error_message: msg.slice(0, 400),
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
docName,
|
||||
upserted: 0,
|
||||
status: 'error',
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
synced: results.filter((r) => r.status === 'synced').length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,11 +52,12 @@ export async function middleware(req: NextRequest) {
|
||||
const url = req.nextUrl.clone();
|
||||
const path = url.pathname;
|
||||
|
||||
// Public: login, attribution script, health, inbound webhooks (CRM)
|
||||
// Public: login, attribution script, health, inbound webhooks (CRM), cron (Bearer CRON_SECRET)
|
||||
const isPublic =
|
||||
path === '/login' ||
|
||||
path === '/api/health' ||
|
||||
path.startsWith('/api/webhooks/') ||
|
||||
path.startsWith('/api/cron/') ||
|
||||
path === '/_4v_attr.js' ||
|
||||
path.startsWith('/_next') ||
|
||||
path === '/favicon.ico' ||
|
||||
|
||||
Reference in New Issue
Block a user