Ship CRM health, cohort CAC, GHL/Shopify sync, and agency alerts.
Add WooCommerce health monitoring, 60d cohort CAC in CRM and portfolio, incremental GHL upsert, Shopify orders→deals, and daily health-alert cron. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -69,6 +69,9 @@ Endpoints individuales (mismo secret):
|
||||
- `/api/cron/superhuman-sync` — Seguimiento / Google Docs
|
||||
- `/api/cron/clientify-sync` — reconciliación CRM Clientify (upsert 60d; diario 05:15 UTC)
|
||||
(script: `python3 scripts/dokploy_ensure_clientify_sync_cron.py`)
|
||||
- `/api/cron/ghl-sync` — GoHighLevel upsert (diario 05:20 UTC)
|
||||
- `/api/cron/shopify-sync` — pedidos paid Shopify → deals (diario 05:25 UTC)
|
||||
- `/api/cron/health-alerts` — alertas sync atrasado / caída ecommerce (diario 07:00 UTC)
|
||||
- `/api/cron/pipeline-publish` — piezas `aprobado` + `scheduled_at` vencida → Meta IG/FB
|
||||
(cada ~10 min; script: `python3 scripts/dokploy_ensure_pipeline_publish_cron.py`)
|
||||
|
||||
|
||||
91
scripts/dokploy_apply_woocommerce_sql.py
Normal file
91
scripts/dokploy_apply_woocommerce_sql.py
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Apply sql/woocommerce_provider.sql via Dokploy supabase-db terminal."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
ROOT = Path("/Users/4ventures/4ventures-client-portal")
|
||||
BPS = Path("/Users/4ventures/Projects/bps-facturacion")
|
||||
SQL = ROOT / "sql" / "woocommerce_provider.sql"
|
||||
BASE = "https://dokploy.4ventures.es"
|
||||
|
||||
|
||||
def env(key: str) -> str:
|
||||
v = os.environ.get(key, "").strip()
|
||||
if v:
|
||||
return v
|
||||
p = BPS / ".env"
|
||||
if not p.is_file():
|
||||
return ""
|
||||
for line in p.read_text().splitlines():
|
||||
if line.startswith(f"{key}="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es"
|
||||
password = env("DOKPLOY_PASSWORD")
|
||||
if not password:
|
||||
print("NO_PASSWORD", flush=True)
|
||||
return 2
|
||||
b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode()
|
||||
chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1500, "height": 1100})
|
||||
page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000)
|
||||
page.wait_for_timeout(500)
|
||||
if "/dashboard" not in page.url:
|
||||
page.locator('input[type="email"], input[name="email"]').first.fill(email)
|
||||
page.locator('input[type="password"]').first.fill(password)
|
||||
page.locator('button[type="submit"]').first.click()
|
||||
page.wait_for_url(re.compile(r"/dashboard"), timeout=90000)
|
||||
print("login ok", flush=True)
|
||||
|
||||
page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000)
|
||||
page.wait_for_timeout(800)
|
||||
page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill(
|
||||
"supabase-db"
|
||||
)
|
||||
page.wait_for_timeout(1200)
|
||||
page.keyboard.press("Escape")
|
||||
page.wait_for_timeout(200)
|
||||
page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click()
|
||||
page.wait_for_timeout(400)
|
||||
page.get_by_text("Terminal", exact=True).click()
|
||||
page.wait_for_timeout(1200)
|
||||
if page.locator("button", has_text="Bash").count():
|
||||
page.locator("button", has_text="Bash").first.click()
|
||||
page.wait_for_timeout(3000)
|
||||
if page.locator("textarea.xterm-helper-textarea").count():
|
||||
page.locator("textarea.xterm-helper-textarea").first.click()
|
||||
|
||||
def run(cmd: str, wait: int = 900) -> None:
|
||||
page.keyboard.type(cmd, delay=1)
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_timeout(wait)
|
||||
|
||||
run("rm -f /tmp/woo.b64 /tmp/woo.sql; echo READY", 1500)
|
||||
for i, ch in enumerate(chunks):
|
||||
run(f"printf '%s' '{ch}' >> /tmp/woo.b64", 200 if i % 5 else 400)
|
||||
run(
|
||||
"base64 -d /tmp/woo.b64 > /tmp/woo.sql && "
|
||||
"psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/woo.sql && echo WOO_SQL_OK",
|
||||
8000,
|
||||
)
|
||||
page.screenshot(path=str(ROOT / "scripts/dokploy-shots/woo_sql.png"))
|
||||
browser.close()
|
||||
print("done", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
32
sql/woocommerce_provider.sql
Normal file
32
sql/woocommerce_provider.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- WooCommerce integration slot (health + future direct order sync)
|
||||
-- Idempotent.
|
||||
|
||||
alter table public.integrations drop constraint if exists integrations_provider_check;
|
||||
alter table public.integrations
|
||||
add constraint integrations_provider_check
|
||||
check (provider in (
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'tag_manager',
|
||||
'clientify',
|
||||
'gohighlevel',
|
||||
'klaviyo',
|
||||
'mailchimp',
|
||||
'google_drive',
|
||||
'wordpress',
|
||||
'prestashop',
|
||||
'shopify',
|
||||
'woocommerce',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'tiktok',
|
||||
'linkedin',
|
||||
'superhuman_docs'
|
||||
));
|
||||
|
||||
insert into public.integrations (tenant_id, provider, status, label)
|
||||
select t.id, 'woocommerce', 'disconnected', 'WooCommerce'
|
||||
from public.tenants t
|
||||
on conflict (tenant_id, provider) do nothing;
|
||||
@@ -52,12 +52,14 @@ export async function POST(req: NextRequest) {
|
||||
['search_console', 'Search Console'],
|
||||
['tag_manager', 'Tag Manager'],
|
||||
['clientify', 'Clientify'],
|
||||
['gohighlevel', 'GoHighLevel'],
|
||||
['klaviyo', 'Klaviyo'],
|
||||
['mailchimp', 'Mailchimp'],
|
||||
['google_drive', 'Google Drive'],
|
||||
['wordpress', 'WordPress'],
|
||||
['prestashop', 'PrestaShop'],
|
||||
['shopify', 'Shopify'],
|
||||
['woocommerce', 'WooCommerce'],
|
||||
['instagram', 'Instagram'],
|
||||
['facebook', 'Facebook Page'],
|
||||
['tiktok', 'TikTok'],
|
||||
|
||||
@@ -7,9 +7,16 @@ import {
|
||||
supabaseUrl,
|
||||
userRole,
|
||||
} from '@/lib/agency';
|
||||
import { portfolioSpendWeightedCac } from '@/lib/metrics';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function dayOffset(days: number): string {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** GET portfolio: tenants + integration summary + unread alerts (agency only). */
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
@@ -32,23 +39,33 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const base = supabaseUrl();
|
||||
const [tenantsRes, integRes, alertsRes] = await Promise.all([
|
||||
fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
|
||||
{
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
const since30 = dayOffset(-30);
|
||||
const [tenantsRes, integRes, alertsRes, spendRes, dealsRes] =
|
||||
await Promise.all([
|
||||
fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
|
||||
{
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/ad_daily?select=tenant_id,spend&date=gte.${since30}&limit=20000`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/deals?select=tenant_id,amount,stage,won_at&stage=eq.won&won_at=gte.${since30}&limit=10000`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
|
||||
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
|
||||
// Fallback if agency_followed column missing
|
||||
@@ -70,6 +87,34 @@ export async function GET() {
|
||||
}
|
||||
const alerts = alertsRes.ok ? await alertsRes.json() : [];
|
||||
|
||||
const spendByTenant = new Map<string, number>();
|
||||
if (spendRes.ok) {
|
||||
for (const row of (await spendRes.json()) as {
|
||||
tenant_id?: string;
|
||||
spend?: number;
|
||||
}[]) {
|
||||
if (!row.tenant_id) continue;
|
||||
spendByTenant.set(
|
||||
row.tenant_id,
|
||||
(spendByTenant.get(row.tenant_id) || 0) + Number(row.spend || 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
const dealsByTenant = new Map<string, number>();
|
||||
if (dealsRes.ok) {
|
||||
for (const row of (await dealsRes.json()) as {
|
||||
tenant_id?: string;
|
||||
amount?: number;
|
||||
}[]) {
|
||||
if (!row.tenant_id) continue;
|
||||
if (!(Number(row.amount || 0) > 0)) continue;
|
||||
dealsByTenant.set(
|
||||
row.tenant_id,
|
||||
(dealsByTenant.get(row.tenant_id) || 0) + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type Integ = {
|
||||
tenant_id: string;
|
||||
provider: string;
|
||||
@@ -106,11 +151,19 @@ export async function GET() {
|
||||
for (const p of INTEGRATION_PROVIDERS) {
|
||||
byProvider[p.id] = rows.find((r) => r.provider === p.id);
|
||||
}
|
||||
const spend30 = spendByTenant.get(t.id) || 0;
|
||||
const deals30 = dealsByTenant.get(t.id) || 0;
|
||||
const cac30 = deals30 > 0 ? spend30 / deals30 : 0;
|
||||
return {
|
||||
...t,
|
||||
agency_followed: t.agency_followed !== false,
|
||||
integrations: rows,
|
||||
byProvider,
|
||||
economics: {
|
||||
spend30,
|
||||
deals30,
|
||||
cac30,
|
||||
},
|
||||
stats: {
|
||||
connected,
|
||||
errors,
|
||||
@@ -129,11 +182,20 @@ export async function GET() {
|
||||
};
|
||||
});
|
||||
|
||||
const portfolioCac = portfolioSpendWeightedCac(
|
||||
portfolio.map((p) => ({
|
||||
spend: p.economics.spend30,
|
||||
deals: p.economics.deals30,
|
||||
}))
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
portfolio,
|
||||
alerts,
|
||||
providers: INTEGRATION_PROVIDERS,
|
||||
schemaReady: !integMissing,
|
||||
portfolioCac30: portfolioCac,
|
||||
economicsWindowDays: 30,
|
||||
warning: integMissing
|
||||
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
|
||||
: null,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
||||
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
|
||||
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
|
||||
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||
import {
|
||||
getAgencyOAuthStatus,
|
||||
@@ -68,14 +69,26 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
try {
|
||||
if (want('crm') || want('metrics')) {
|
||||
const [clientify, gohighlevel] = await Promise.all([
|
||||
const [clientify, gohighlevel, shopify] = await Promise.all([
|
||||
syncAllClientifyTenants({ tenantId }),
|
||||
syncAllGhlTenants({ tenantId }),
|
||||
syncAllShopifyTenants({ tenantId }),
|
||||
]);
|
||||
out.crm = {
|
||||
results: [...clientify.results, ...gohighlevel.results],
|
||||
results: [
|
||||
...clientify.results,
|
||||
...gohighlevel.results,
|
||||
...shopify.results.map((r) => ({
|
||||
tenantId: r.tenantId,
|
||||
tenantSlug: r.tenantSlug,
|
||||
leads: 0,
|
||||
deals: r.orders,
|
||||
error: r.error,
|
||||
})),
|
||||
],
|
||||
clientify,
|
||||
gohighlevel,
|
||||
shopify,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from '@/lib/superhuman-docs';
|
||||
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
||||
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
|
||||
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
|
||||
import { runAgencyHealthAlerts } from '@/lib/agency-health';
|
||||
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -92,13 +94,25 @@ export async function GET(req: NextRequest) {
|
||||
};
|
||||
}
|
||||
|
||||
// 4) Clientify CRM → leads/deals (prefer dedicated /api/cron/clientify-sync)
|
||||
// 4) CRM + Shopify (prefer dedicated crons; safety net here)
|
||||
try {
|
||||
const [clientify, gohighlevel] = await Promise.all([
|
||||
syncAllClientifyTenants({ lookbackDays: Math.max(days * 2, 30) }),
|
||||
syncAllGhlTenants(),
|
||||
const lookback = Math.max(days * 2, 30);
|
||||
const [clientify, gohighlevel, shopify] = await Promise.all([
|
||||
syncAllClientifyTenants({ lookbackDays: lookback }),
|
||||
syncAllGhlTenants({ lookbackDays: lookback }),
|
||||
syncAllShopifyTenants({ lookbackDays: lookback }),
|
||||
]);
|
||||
const results = [...clientify.results, ...gohighlevel.results];
|
||||
const results = [
|
||||
...clientify.results,
|
||||
...gohighlevel.results,
|
||||
...shopify.results.map((r) => ({
|
||||
tenantId: r.tenantId,
|
||||
tenantSlug: r.tenantSlug,
|
||||
leads: 0,
|
||||
deals: r.orders,
|
||||
error: r.error,
|
||||
})),
|
||||
];
|
||||
out.crm = {
|
||||
ok: true,
|
||||
synced: results.filter((r) => !r.error).length,
|
||||
@@ -122,6 +136,16 @@ export async function GET(req: NextRequest) {
|
||||
};
|
||||
}
|
||||
|
||||
// 6) Health alerts (stale sync / ecommerce drop)
|
||||
try {
|
||||
out.healthAlerts = await runAgencyHealthAlerts();
|
||||
} catch (e) {
|
||||
out.healthAlerts = {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'health-alerts failed',
|
||||
};
|
||||
}
|
||||
|
||||
const failed = ['organic', 'ads', 'superhuman', 'crm'].some(
|
||||
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
|
||||
);
|
||||
|
||||
51
src/app/api/cron/ghl-sync/route.ts
Normal file
51
src/app/api/cron/ghl-sync/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily GoHighLevel reconciliation (upsert, no wipe).
|
||||
* Dokploy ~05:20 UTC:
|
||||
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||
* "https://app.4ventures.es/api/cron/ghl-sync"
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const lookbackDays = Math.min(
|
||||
120,
|
||||
Math.max(14, Number(req.nextUrl.searchParams.get('lookback')) || 60)
|
||||
);
|
||||
const tenantId = req.nextUrl.searchParams.get('tenant_id') || undefined;
|
||||
|
||||
try {
|
||||
const { results } = await syncAllGhlTenants({ tenantId, lookbackDays });
|
||||
const withErrors = results.filter((r) => r.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: withErrors.length === 0,
|
||||
at: new Date().toISOString(),
|
||||
lookbackDays,
|
||||
synced: results.filter((r) => !r.error).length,
|
||||
withErrors: withErrors.length,
|
||||
results,
|
||||
},
|
||||
{ status: withErrors.length ? 207 : 200 }
|
||||
);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'ghl-sync failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
38
src/app/api/cron/health-alerts/route.ts
Normal file
38
src/app/api/cron/health-alerts/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { runAgencyHealthAlerts } from '@/lib/agency-health';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 120;
|
||||
|
||||
/**
|
||||
* Daily agency health → agency_alerts (stale sync, ecommerce drop).
|
||||
* Dokploy ~07:00 UTC:
|
||||
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
|
||||
* "https://app.4ventures.es/api/cron/health-alerts"
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
try {
|
||||
const result = await runAgencyHealthAlerts();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
at: new Date().toISOString(),
|
||||
...result,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'health-alerts failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
52
src/app/api/cron/shopify-sync/route.ts
Normal file
52
src/app/api/cron/shopify-sync/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { assertCronAuth } from '@/lib/cron-auth';
|
||||
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* Daily Shopify paid-orders → deals.
|
||||
* Requires integrations.meta.access_token + shop / external_account_id.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = assertCronAuth(req);
|
||||
if (denied) return denied;
|
||||
|
||||
const lookbackDays = Math.min(
|
||||
120,
|
||||
Math.max(14, Number(req.nextUrl.searchParams.get('lookback')) || 60)
|
||||
);
|
||||
const tenantId = req.nextUrl.searchParams.get('tenant_id') || undefined;
|
||||
|
||||
try {
|
||||
const { results } = await syncAllShopifyTenants({
|
||||
tenantId,
|
||||
lookbackDays,
|
||||
});
|
||||
const withErrors = results.filter((r) => r.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: withErrors.length === 0,
|
||||
at: new Date().toISOString(),
|
||||
lookbackDays,
|
||||
synced: results.filter((r) => !r.error).length,
|
||||
withErrors: withErrors.length,
|
||||
results,
|
||||
},
|
||||
{ status: withErrors.length ? 207 : 200 }
|
||||
);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : 'shopify-sync failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return GET(req);
|
||||
}
|
||||
@@ -16,11 +16,11 @@ 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';
|
||||
? 'id,tenant_id,source,created_at,status,updated_at,status_changed_at'
|
||||
: 'id,source,created_at,status,updated_at,status_changed_at';
|
||||
const selectFallback = includeTenantId
|
||||
? 'tenant_id,source,created_at,status'
|
||||
: 'source,created_at,status';
|
||||
? 'id,tenant_id,source,created_at,status'
|
||||
: 'id,source,created_at,status';
|
||||
const pageSize = 1000;
|
||||
const out: CrmLeadRow[] = [];
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ type PortfolioRow = {
|
||||
agency_followed?: boolean;
|
||||
agency_notes?: string | null;
|
||||
byProvider?: Record<string, IntegCell | undefined>;
|
||||
economics?: {
|
||||
spend30: number;
|
||||
deals30: number;
|
||||
cac30: number;
|
||||
};
|
||||
stats: {
|
||||
connected: number;
|
||||
errors: number;
|
||||
@@ -120,6 +125,7 @@ export default function AgencyPortfolioBoard() {
|
||||
const [providers, setProviders] = useState<Provider[]>([]);
|
||||
const [alerts, setAlerts] = useState<AlertRow[]>([]);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [portfolioCac30, setPortfolioCac30] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
@@ -132,6 +138,7 @@ export default function AgencyPortfolioBoard() {
|
||||
setProviders(data.providers || []);
|
||||
setAlerts((data.alerts || []).filter((a: AlertRow) => !a.read_at));
|
||||
setWarning(data.warning || null);
|
||||
setPortfolioCac30(Number(data.portfolioCac30 || 0));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -192,6 +199,19 @@ export default function AgencyPortfolioBoard() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{portfolioCac30 > 0 && (
|
||||
<p className="text-xs text-gray-600 border border-gray-200 rounded-lg px-3 py-2 bg-white">
|
||||
CAC portafolio 30d (ponderado por spend):{' '}
|
||||
<span className="font-semibold text-gray-900 tabular-nums">
|
||||
{portfolioCac30.toLocaleString('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{alerts.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
||||
@@ -310,6 +330,13 @@ export default function AgencyPortfolioBoard() {
|
||||
<p className="text-[11px] text-gray-500 truncate">
|
||||
{t.slug} · {t.stats.connected}/
|
||||
{t.stats.totalProviders} conectadas
|
||||
{t.economics && t.economics.deals30 > 0
|
||||
? ` · CAC 30d ${t.economics.cac30.toLocaleString('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
maximumFractionDigits: 0,
|
||||
})} (${t.economics.deals30} ventas)`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ const PLACEHOLDERS: Record<string, string> = {
|
||||
google_drive: 'URL de carpeta Drive o folder id',
|
||||
wordpress: 'https://sitio.com + Application Password',
|
||||
prestashop: 'https://tienda.com + API key',
|
||||
shopify: 'mitienda.myshopify.com + Admin API token',
|
||||
shopify:
|
||||
'Shop (mitienda.myshopify.com) en ID externo + Admin API token en meta.access_token',
|
||||
woocommerce:
|
||||
'Slot de salud Woo→CRM. Pedidos llegan vía Clientify; API Woo directa = siguiente fase',
|
||||
instagram: '@handle o Instagram Business Account ID',
|
||||
facebook: 'Page ID / facebook.com/…',
|
||||
tiktok: '@handle o Advertiser / Business ID',
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
buildCrmChannelStats,
|
||||
buildCrmFunnel,
|
||||
buildCrmStatusBreakdown,
|
||||
DEFAULT_COHORT_WINDOW_DAYS,
|
||||
cohortWonDeals,
|
||||
filterDailyByDayRange,
|
||||
filterDealsForRange,
|
||||
filterLeadsByDayRange,
|
||||
@@ -115,6 +117,21 @@ export default function CrmDashboard({
|
||||
const cacGlobal =
|
||||
ventasTotal > 0 && spendTotal > 0 ? spendTotal / ventasTotal : 0;
|
||||
|
||||
const cohortDeals = useMemo(() => {
|
||||
if (!since || !until) return [];
|
||||
return cohortWonDeals({
|
||||
leads: crmLeads,
|
||||
deals: wonDeals,
|
||||
since,
|
||||
until,
|
||||
windowDays: DEFAULT_COHORT_WINDOW_DAYS,
|
||||
});
|
||||
}, [crmLeads, wonDeals, since, until]);
|
||||
const cacCohort =
|
||||
cohortDeals.length > 0 && spendTotal > 0
|
||||
? spendTotal / cohortDeals.length
|
||||
: 0;
|
||||
|
||||
const cacByChannel = useMemo(() => {
|
||||
const m = new Map<
|
||||
string,
|
||||
@@ -210,9 +227,15 @@ export default function CrmDashboard({
|
||||
source=google (hay que mapear la fuente en el CRM).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Ventas / CAC</strong> usan deals won del CRM. Un pedido
|
||||
WooCommerce suele entrar como <em>ecommerce</em>, aunque el clic haya
|
||||
sido de Google Brand — sin UTM/gclid no se reasigna a Google.
|
||||
<strong>Ventas / CAC caja</strong> = deals won con <em>won_at</em> en
|
||||
el periodo. Un pedido WooCommerce suele entrar como{' '}
|
||||
<em>ecommerce</em> sin UTM/gclid.
|
||||
</p>
|
||||
<p>
|
||||
<strong>CAC cohort ({DEFAULT_COHORT_WINDOW_DAYS}d)</strong> = inversión
|
||||
del periodo ÷ leads creados en el periodo que cerraron en ≤
|
||||
{DEFAULT_COHORT_WINDOW_DAYS} días. Mejor para leer adquisición del
|
||||
mes.
|
||||
</p>
|
||||
</HelpDetails>
|
||||
|
||||
@@ -232,7 +255,7 @@ export default function CrmDashboard({
|
||||
) : null}
|
||||
|
||||
<HeroBand>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 md:gap-6">
|
||||
<HeroStat label="Leads" value={formatNum(funnel.total)} />
|
||||
<HeroStat
|
||||
label="Clientes"
|
||||
@@ -248,7 +271,7 @@ export default function CrmDashboard({
|
||||
value={`${funnel.clienteRate.toFixed(1)}%`}
|
||||
/>
|
||||
<HeroStat
|
||||
label="CAC global"
|
||||
label="CAC caja"
|
||||
value={cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
|
||||
chip={
|
||||
<span className="text-[11px] text-gray-500">
|
||||
@@ -258,6 +281,17 @@ export default function CrmDashboard({
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label={`CAC cohort ${DEFAULT_COHORT_WINDOW_DAYS}d`}
|
||||
value={cacCohort > 0 ? formatEurCents(cacCohort) : '—'}
|
||||
chip={
|
||||
<span className="text-[11px] text-gray-500">
|
||||
{cohortDeals.length > 0
|
||||
? `${formatNum(cohortDeals.length)} cierres de altas del periodo`
|
||||
: 'Sin cierres cohort (falta lead_id o ventana)'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<HeroStat
|
||||
label="Perdidos"
|
||||
value={formatNum(funnel.leadPerdido)}
|
||||
|
||||
245
src/lib/agency-health.ts
Normal file
245
src/lib/agency-health.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/** Agency health checks → agency_alerts (sync stale, ecommerce volume drop). */
|
||||
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
const STALE_HOURS = 26;
|
||||
const ECOM_DROP_RATIO = 0.4; // alert if this week < 40% of prior week
|
||||
const ECOM_MIN_PRIOR = 3;
|
||||
|
||||
type AlertDraft = {
|
||||
tenant_id: string | null;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
source: string;
|
||||
title: string;
|
||||
body: string;
|
||||
href?: string | null;
|
||||
};
|
||||
|
||||
function hoursAgo(iso: string | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
return (Date.now() - t) / 36e5;
|
||||
}
|
||||
|
||||
function dayOffset(days: number): string {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function existingUnreadTitle(
|
||||
headers: Record<string, string>,
|
||||
base: string,
|
||||
title: string,
|
||||
tenantId: string | null
|
||||
): Promise<boolean> {
|
||||
const since = new Date(Date.now() - 36e5 * 36).toISOString();
|
||||
let q = `${base}/rest/v1/agency_alerts?select=id&title=eq.${encodeURIComponent(title)}&read_at=is.null&created_at=gte.${since}&limit=1`;
|
||||
if (tenantId) {
|
||||
q += `&tenant_id=eq.${encodeURIComponent(tenantId)}`;
|
||||
} else {
|
||||
q += `&tenant_id=is.null`;
|
||||
}
|
||||
const r = await fetch(q, { headers, cache: 'no-store' });
|
||||
if (!r.ok) return false;
|
||||
const rows = (await r.json()) as unknown[];
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function insertAlert(
|
||||
headers: Record<string, string>,
|
||||
base: string,
|
||||
draft: AlertDraft
|
||||
): Promise<boolean> {
|
||||
if (await existingUnreadTitle(headers, base, draft.title, draft.tenant_id)) {
|
||||
return false;
|
||||
}
|
||||
const r = await fetch(`${base}/rest/v1/agency_alerts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: draft.tenant_id,
|
||||
severity: draft.severity,
|
||||
source: draft.source,
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
href: draft.href || null,
|
||||
}),
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
const CRM_PROVIDERS = ['clientify', 'gohighlevel'] as const;
|
||||
const DATA_PROVIDERS = [
|
||||
'clientify',
|
||||
'gohighlevel',
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'shopify',
|
||||
'woocommerce',
|
||||
] as const;
|
||||
|
||||
function isEcommerceDealName(name: string | null | undefined): boolean {
|
||||
return /^(order|pedido)\s+\d+/i.test(String(name || ''));
|
||||
}
|
||||
|
||||
export type HealthAlertResult = {
|
||||
checked: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
details: string[];
|
||||
};
|
||||
|
||||
/** Run health checks and write unread agency_alerts (deduped 36h). */
|
||||
export async function runAgencyHealthAlerts(): Promise<HealthAlertResult> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
const out: HealthAlertResult = {
|
||||
checked: 0,
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
details: [],
|
||||
};
|
||||
if (!headers || !base) {
|
||||
out.details.push('no service role');
|
||||
return out;
|
||||
}
|
||||
|
||||
const [tRes, iRes] = await Promise.all([
|
||||
fetch(
|
||||
`${base}/rest/v1/tenants?select=id,slug,name&agency_followed=neq.false`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/integrations?select=tenant_id,provider,status,last_sync_at,enabled,error_message&enabled=neq.false`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
if (!tRes.ok || !iRes.ok) {
|
||||
out.details.push('fetch tenants/integrations failed');
|
||||
return out;
|
||||
}
|
||||
|
||||
const tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}[];
|
||||
const integrations = (await iRes.json()) as {
|
||||
tenant_id: string;
|
||||
provider: string;
|
||||
status?: string;
|
||||
last_sync_at?: string | null;
|
||||
error_message?: string | null;
|
||||
}[];
|
||||
|
||||
const byTenant = new Map<string, typeof integrations>();
|
||||
for (const row of integrations) {
|
||||
const list = byTenant.get(row.tenant_id) || [];
|
||||
list.push(row);
|
||||
byTenant.set(row.tenant_id, list);
|
||||
}
|
||||
|
||||
const thisWeekStart = dayOffset(-7);
|
||||
const thisWeekEnd = dayOffset(0);
|
||||
const priorStart = dayOffset(-14);
|
||||
const priorEnd = dayOffset(-7);
|
||||
|
||||
for (const t of tenants) {
|
||||
out.checked += 1;
|
||||
const rows = byTenant.get(t.id) || [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!DATA_PROVIDERS.includes(row.provider as (typeof DATA_PROVIDERS)[number])) {
|
||||
continue;
|
||||
}
|
||||
if (row.status === 'error') {
|
||||
const title = `Integración en error: ${row.provider} · ${t.name}`;
|
||||
const ok = await insertAlert(headers, base, {
|
||||
tenant_id: t.id,
|
||||
severity: 'critical',
|
||||
source: 'health',
|
||||
title,
|
||||
body: row.error_message || 'Revisa credenciales / sync en ficha cliente.',
|
||||
href: `/dashboard/agencia/cliente/${t.id}`,
|
||||
});
|
||||
if (ok) {
|
||||
out.created += 1;
|
||||
out.details.push(title);
|
||||
} else out.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (row.status !== 'connected') continue;
|
||||
const age = hoursAgo(row.last_sync_at);
|
||||
if (age == null || age > STALE_HOURS) {
|
||||
const title = `Sync atrasado: ${row.provider} · ${t.name}`;
|
||||
const ok = await insertAlert(headers, base, {
|
||||
tenant_id: t.id,
|
||||
severity: CRM_PROVIDERS.includes(
|
||||
row.provider as (typeof CRM_PROVIDERS)[number]
|
||||
)
|
||||
? 'critical'
|
||||
: 'warning',
|
||||
source: 'health',
|
||||
title,
|
||||
body:
|
||||
age == null
|
||||
? 'Conectado pero sin last_sync_at. Lanza sync o revisa el cron.'
|
||||
: `Último sync hace ${Math.round(age)}h (umbral ${STALE_HOURS}h).`,
|
||||
href: `/dashboard/agencia/cliente/${t.id}`,
|
||||
});
|
||||
if (ok) {
|
||||
out.created += 1;
|
||||
out.details.push(title);
|
||||
} else out.skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Ecommerce volume: Order/Pedido won deals this week vs prior
|
||||
const dealsRes = await fetch(
|
||||
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(t.id)}&stage=eq.won&won_at=gte.${priorStart}&won_at=lt.${thisWeekEnd}&select=name,won_at,amount&limit=2000`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!dealsRes.ok) continue;
|
||||
const deals = (await dealsRes.json()) as {
|
||||
name?: string | null;
|
||||
won_at?: string | null;
|
||||
amount?: number | null;
|
||||
}[];
|
||||
const ecom = deals.filter(
|
||||
(d) => isEcommerceDealName(d.name) && Number(d.amount || 0) > 0
|
||||
);
|
||||
const thisW = ecom.filter((d) => {
|
||||
const day = (d.won_at || '').slice(0, 10);
|
||||
return day >= thisWeekStart && day < thisWeekEnd;
|
||||
}).length;
|
||||
const priorW = ecom.filter((d) => {
|
||||
const day = (d.won_at || '').slice(0, 10);
|
||||
return day >= priorStart && day < priorEnd;
|
||||
}).length;
|
||||
if (priorW >= ECOM_MIN_PRIOR && thisW < priorW * ECOM_DROP_RATIO) {
|
||||
const title = `Caída ventas ecommerce · ${t.name}`;
|
||||
const ok = await insertAlert(headers, base, {
|
||||
tenant_id: t.id,
|
||||
severity: 'warning',
|
||||
source: 'ecommerce-health',
|
||||
title,
|
||||
body: `Pedidos CRM (Order/Pedido) esta semana: ${thisW} vs ${priorW} la anterior. Posible fallo Woo→Clientify/Shopify.`,
|
||||
href: `/dashboard/crm`,
|
||||
});
|
||||
if (ok) {
|
||||
out.created += 1;
|
||||
out.details.push(title);
|
||||
} else out.skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -13,7 +13,8 @@ export const INTEGRATION_PROVIDERS = [
|
||||
{ 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: 'shopify', label: 'Shopify', group: 'cms', live: true },
|
||||
{ id: 'woocommerce', label: 'WooCommerce', group: 'cms', live: true },
|
||||
{ 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 },
|
||||
|
||||
@@ -147,14 +147,40 @@ export type GhlSyncResult = {
|
||||
tenantSlug: string;
|
||||
leads: number;
|
||||
deals: number;
|
||||
lookbackDays?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function upsertChunks(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
rows: Record<string, unknown>[],
|
||||
label: string
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < rows.length; i += 100) {
|
||||
const chunk = rows.slice(i, i + 100);
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`${label}: ${(await r.text()).slice(0, 220)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncGhlTenant(opts: {
|
||||
tenantId: string;
|
||||
slug: string;
|
||||
pit?: string | null;
|
||||
locationId?: string | null;
|
||||
/** Kept for API parity with Clientify; GHL search is full upsert (no wipe). */
|
||||
lookbackDays?: number;
|
||||
}): Promise<GhlSyncResult> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
@@ -192,17 +218,32 @@ export async function syncGhlTenant(opts: {
|
||||
}
|
||||
|
||||
const tid = opts.tenantId;
|
||||
const lookbackDays = opts.lookbackDays ?? 60;
|
||||
try {
|
||||
// Existing leads for FK-safe deal linking
|
||||
const contactToLead = new Map<string, string>();
|
||||
for (let offset = 0; offset < 20000; offset += 1000) {
|
||||
const lr = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.gohighlevel&select=id,crm_contact_id&limit=1000&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!lr.ok) break;
|
||||
const rows = (await lr.json()) as {
|
||||
id: string;
|
||||
crm_contact_id?: string | null;
|
||||
}[];
|
||||
if (!rows.length) break;
|
||||
for (const row of rows) {
|
||||
if (row.crm_contact_id) contactToLead.set(row.crm_contact_id, row.id);
|
||||
}
|
||||
if (rows.length < 1000) break;
|
||||
}
|
||||
|
||||
const contacts = await listContacts(token, locationId);
|
||||
const uniqueContacts = [
|
||||
...new Map(contacts.map((c) => [String(c.id), c])).values(),
|
||||
];
|
||||
await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.gohighlevel`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
|
||||
const contactToLead = new Map<string, string>();
|
||||
const leadRows = uniqueContacts.map((c) => {
|
||||
const cid = String(c.id);
|
||||
const lid = uuid5(`ghl-lead:${tid}:${cid}`);
|
||||
@@ -238,22 +279,16 @@ export async function syncGhlTenant(opts: {
|
||||
created_at: parseDate(c.dateAdded) || new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
for (let i = 0; i < leadRows.length; i += 200) {
|
||||
const chunk = leadRows.slice(i, i + 200);
|
||||
const r = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal,resolution=merge-duplicates',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`insert leads: ${(await r.text()).slice(0, 180)}`);
|
||||
}
|
||||
for (const row of leadRows) {
|
||||
const cid = String(row.crm_contact_id || '');
|
||||
if (cid) contactToLead.set(cid, String(row.id));
|
||||
}
|
||||
await upsertChunks(
|
||||
`${base}/rest/v1/leads`,
|
||||
headers,
|
||||
leadRows,
|
||||
'upsert ghl leads'
|
||||
);
|
||||
|
||||
const [opps, pipeNames] = await Promise.all([
|
||||
listOpportunities(token, locationId),
|
||||
@@ -262,10 +297,6 @@ export async function syncGhlTenant(opts: {
|
||||
const uniqueOpps = [
|
||||
...new Map(opps.map((d) => [String(d.id), d])).values(),
|
||||
];
|
||||
await fetch(
|
||||
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tid)}&crm_deal_id=like.ghl:*`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
|
||||
const dealRows = uniqueOpps.map((d) => {
|
||||
const did = String(d.id);
|
||||
@@ -294,22 +325,12 @@ export async function syncGhlTenant(opts: {
|
||||
name: d.name ? String(d.name).slice(0, 200) : null,
|
||||
};
|
||||
});
|
||||
|
||||
for (let i = 0; i < dealRows.length; i += 200) {
|
||||
const chunk = dealRows.slice(i, i + 200);
|
||||
const r = await fetch(`${base}/rest/v1/deals`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal,resolution=merge-duplicates',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`insert deals: ${(await r.text()).slice(0, 180)}`);
|
||||
}
|
||||
}
|
||||
await upsertChunks(
|
||||
`${base}/rest/v1/deals`,
|
||||
headers,
|
||||
dealRows,
|
||||
'upsert ghl deals'
|
||||
);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await fetch(
|
||||
@@ -319,7 +340,7 @@ export async function syncGhlTenant(opts: {
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal,resolution=merge-duplicates',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: 'connected',
|
||||
@@ -327,6 +348,13 @@ export async function syncGhlTenant(opts: {
|
||||
error_message: null,
|
||||
external_account_id: locationId,
|
||||
label: 'GoHighLevel',
|
||||
meta: {
|
||||
source: 'cron',
|
||||
synced_at: now,
|
||||
lookback_days: lookbackDays,
|
||||
leads_upserted: leadRows.length,
|
||||
deals_upserted: dealRows.length,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -336,6 +364,7 @@ export async function syncGhlTenant(opts: {
|
||||
tenantSlug: opts.slug,
|
||||
leads: leadRows.length,
|
||||
deals: dealRows.length,
|
||||
lookbackDays,
|
||||
};
|
||||
} catch (e) {
|
||||
await fetch(
|
||||
@@ -365,6 +394,7 @@ export async function syncGhlTenant(opts: {
|
||||
|
||||
export async function syncAllGhlTenants(opts?: {
|
||||
tenantId?: string;
|
||||
lookbackDays?: number;
|
||||
}): Promise<{ results: GhlSyncResult[] }> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
@@ -424,6 +454,7 @@ export async function syncAllGhlTenants(opts?: {
|
||||
slug: t.slug,
|
||||
pit,
|
||||
locationId,
|
||||
lookbackDays: opts?.lookbackDays,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,15 @@ export type MetricTotals = {
|
||||
cpl: number;
|
||||
};
|
||||
|
||||
/** Default attribution window for cohort CAC (days after lead create). */
|
||||
export const DEFAULT_COHORT_WINDOW_DAYS = 60;
|
||||
|
||||
export type CohortCacMetrics = {
|
||||
cac_cohort: number;
|
||||
cohort_deals_won: number;
|
||||
cohort_revenue_won: number;
|
||||
};
|
||||
|
||||
export type Delta = {
|
||||
wow: number | null;
|
||||
mom: number | null;
|
||||
@@ -623,6 +632,69 @@ export function withCohortFromDeals(
|
||||
};
|
||||
}
|
||||
|
||||
function addUtcDays(day: string, days: number): string {
|
||||
const d = new Date(`${day}T12:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cohort closes: leads created in [since, until] that won within
|
||||
* `windowDays` of creation (default 60). Cash CAC stays on won_at.
|
||||
*/
|
||||
export function cohortWonDeals(opts: {
|
||||
leads: CrmLeadRow[];
|
||||
deals: DealWonRow[];
|
||||
since: string;
|
||||
until: string;
|
||||
windowDays?: number;
|
||||
}): DealWonRow[] {
|
||||
const windowDays = opts.windowDays ?? DEFAULT_COHORT_WINDOW_DAYS;
|
||||
const leadById = new Map<string, CrmLeadRow>();
|
||||
for (const l of opts.leads) {
|
||||
if (l.id) leadById.set(l.id, l);
|
||||
}
|
||||
const paid = preferOnlinePaidDeals(
|
||||
opts.deals.filter((d) => !d.stage || d.stage === 'won')
|
||||
);
|
||||
return paid.filter((d) => {
|
||||
const won = (d.won_at || '').slice(0, 10);
|
||||
if (!won || !d.lead_id) return false;
|
||||
const lead = leadById.get(d.lead_id);
|
||||
const created = (lead?.created_at || '').slice(0, 10);
|
||||
if (!created) return false;
|
||||
if (created < opts.since || created > opts.until) return false;
|
||||
const maxWon = addUtcDays(created, windowDays);
|
||||
return won >= created && won <= maxWon;
|
||||
});
|
||||
}
|
||||
|
||||
export function withCohortCacMetrics(
|
||||
spend: number,
|
||||
cohortDeals: DealWonRow[]
|
||||
): CohortCacMetrics {
|
||||
const deals = cohortDeals.length;
|
||||
const revenue = cohortDeals.reduce((a, d) => a + n(d.amount), 0);
|
||||
return {
|
||||
cac_cohort: deals > 0 ? spend / deals : 0,
|
||||
cohort_deals_won: deals,
|
||||
cohort_revenue_won: Math.round(revenue * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/** Portfolio blended CAC: spend-weighted average of per-tenant cash CACs. */
|
||||
export function portfolioSpendWeightedCac(
|
||||
rows: { spend: number; deals: number }[]
|
||||
): number {
|
||||
let spend = 0;
|
||||
let deals = 0;
|
||||
for (const r of rows) {
|
||||
spend += n(r.spend);
|
||||
deals += n(r.deals);
|
||||
}
|
||||
return deals > 0 ? spend / deals : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject won-deal cohort into weekly rows so WoW/MoM/YoY include CRM closes.
|
||||
* Ads sync leaves cohort_* at 0; without this, CAC/ROAS deltas stay empty.
|
||||
@@ -664,6 +736,7 @@ export function mergeDealsIntoWeekly(
|
||||
|
||||
/** CRM lead row used for channel attribution cross. */
|
||||
export type CrmLeadRow = {
|
||||
id?: string | null;
|
||||
source?: string | null;
|
||||
created_at?: string | null;
|
||||
/** Last status change (→ cliente). Prefer for “ventas en periodo”. */
|
||||
|
||||
319
src/lib/shopify-sync.ts
Normal file
319
src/lib/shopify-sync.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/** Pull Shopify paid orders → warehouse deals (crm_deal_id shopify:*). */
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
const API_VERSION = '2024-10';
|
||||
|
||||
function uuid5(name: string): string {
|
||||
const NS = Buffer.from('6ba7b8119dad11d180b400c04fd430c8', 'hex');
|
||||
const hash = createHash('sha1').update(NS).update(name).digest();
|
||||
hash[6] = (hash[6]! & 0x0f) | 0x50;
|
||||
hash[8] = (hash[8]! & 0x3f) | 0x80;
|
||||
const h = hash.subarray(0, 16).toString('hex');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
function normalizeShop(shop: string): string {
|
||||
let s = shop.trim().toLowerCase().replace(/^https?:\/\//, '');
|
||||
s = s.split('/')[0] || s;
|
||||
if (!s.includes('.')) s = `${s}.myshopify.com`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function lookbackIso(days: number): string {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() - Math.max(1, days));
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
async function upsertChunks(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
rows: Record<string, unknown>[],
|
||||
label: string
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < rows.length; i += 100) {
|
||||
const chunk = rows.slice(i, i + 100);
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`${label}: ${(await r.text()).slice(0, 220)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ShopifySyncResult = {
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
orders: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function syncShopifyTenant(opts: {
|
||||
tenantId: string;
|
||||
slug: string;
|
||||
shop?: string | null;
|
||||
accessToken?: string | null;
|
||||
lookbackDays?: number;
|
||||
}): Promise<ShopifySyncResult> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
const lookbackDays = opts.lookbackDays ?? 60;
|
||||
if (!headers || !base) {
|
||||
return {
|
||||
tenantId: opts.tenantId,
|
||||
tenantSlug: opts.slug,
|
||||
orders: 0,
|
||||
error: 'no service role',
|
||||
};
|
||||
}
|
||||
|
||||
const shopRaw = opts.shop?.trim() || null;
|
||||
const token = opts.accessToken?.trim() || null;
|
||||
if (!shopRaw || !token) {
|
||||
return {
|
||||
tenantId: opts.tenantId,
|
||||
tenantSlug: opts.slug,
|
||||
orders: 0,
|
||||
error: 'Falta shop (*.myshopify.com) y Admin API token en la integración',
|
||||
};
|
||||
}
|
||||
|
||||
const shop = normalizeShop(shopRaw);
|
||||
const since = lookbackIso(lookbackDays);
|
||||
const tid = opts.tenantId;
|
||||
|
||||
try {
|
||||
const dealRows: Record<string, unknown>[] = [];
|
||||
let pageInfo: string | null = null;
|
||||
for (let page = 0; page < 30; page++) {
|
||||
const url = new URL(
|
||||
`https://${shop}/admin/api/${API_VERSION}/orders.json`
|
||||
);
|
||||
if (pageInfo) {
|
||||
url.searchParams.set('page_info', pageInfo);
|
||||
url.searchParams.set('limit', '100');
|
||||
} else {
|
||||
url.searchParams.set('status', 'any');
|
||||
url.searchParams.set('financial_status', 'paid');
|
||||
url.searchParams.set('updated_at_min', since);
|
||||
url.searchParams.set('limit', '100');
|
||||
url.searchParams.set('order', 'updated_at asc');
|
||||
}
|
||||
|
||||
const r = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'X-Shopify-Access-Token': token,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`Shopify orders: ${(await r.text()).slice(0, 180)}`);
|
||||
}
|
||||
const j = (await r.json()) as { orders?: Record<string, unknown>[] };
|
||||
const batch = j.orders || [];
|
||||
for (const o of batch) {
|
||||
const oid = String(o.id || '');
|
||||
if (!oid) continue;
|
||||
const name = String(o.name || `Order ${o.order_number || oid}`).slice(
|
||||
0,
|
||||
200
|
||||
);
|
||||
const amount = Number(o.total_price || 0) || 0;
|
||||
const closed =
|
||||
(typeof o.processed_at === 'string' && o.processed_at) ||
|
||||
(typeof o.created_at === 'string' && o.created_at) ||
|
||||
null;
|
||||
const wonAt = closed ? new Date(closed).toISOString() : null;
|
||||
// Attribution hints from note_attributes / tags if present
|
||||
let source = 'ecommerce';
|
||||
const tags = String(o.tags || '').toLowerCase();
|
||||
if (/gclid|google|utm_source=google/.test(tags)) source = 'google';
|
||||
else if (/fbclid|meta|facebook|instagram/.test(tags)) source = 'meta';
|
||||
const attrs = Array.isArray(o.note_attributes)
|
||||
? (o.note_attributes as { name?: string; value?: string }[])
|
||||
: [];
|
||||
for (const a of attrs) {
|
||||
const n = String(a.name || '').toLowerCase();
|
||||
const v = String(a.value || '').toLowerCase();
|
||||
if (n.includes('gclid') || v.includes('gclid') || n === 'utm_source') {
|
||||
if (v.includes('google') || n.includes('gclid')) source = 'google';
|
||||
if (v.includes('facebook') || v.includes('instagram') || v.includes('meta'))
|
||||
source = 'meta';
|
||||
}
|
||||
}
|
||||
dealRows.push({
|
||||
id: uuid5(`shopify-deal:${tid}:${oid}`),
|
||||
tenant_id: tid,
|
||||
lead_id: null,
|
||||
crm_deal_id: `shopify:${oid}`,
|
||||
stage: 'won',
|
||||
amount,
|
||||
won_at: wonAt,
|
||||
lost_at: null,
|
||||
pipeline_id: null,
|
||||
pipeline_name: 'Shopify',
|
||||
source,
|
||||
name: name.startsWith('#') ? `Order ${name.slice(1)}` : name,
|
||||
});
|
||||
}
|
||||
|
||||
const link = r.headers.get('link') || '';
|
||||
const next = link.match(/<([^>]+)>;\s*rel="next"/);
|
||||
if (!next || batch.length < 100) break;
|
||||
try {
|
||||
pageInfo = new URL(next[1]!).searchParams.get('page_info');
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (!pageInfo) break;
|
||||
}
|
||||
|
||||
await upsertChunks(
|
||||
`${base}/rest/v1/deals`,
|
||||
headers,
|
||||
dealRows,
|
||||
'upsert shopify deals'
|
||||
);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=eq.shopify`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: 'connected',
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
external_account_id: shop,
|
||||
label: 'Shopify',
|
||||
meta: {
|
||||
source: 'cron',
|
||||
synced_at: now,
|
||||
lookback_days: lookbackDays,
|
||||
orders_upserted: dealRows.length,
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
tenantId: tid,
|
||||
tenantSlug: opts.slug,
|
||||
orders: dealRows.length,
|
||||
};
|
||||
} catch (e) {
|
||||
await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=eq.shopify`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: 'error',
|
||||
error_message:
|
||||
e instanceof Error ? e.message.slice(0, 240) : 'shopify sync failed',
|
||||
}),
|
||||
}
|
||||
).catch(() => null);
|
||||
return {
|
||||
tenantId: tid,
|
||||
tenantSlug: opts.slug,
|
||||
orders: 0,
|
||||
error: e instanceof Error ? e.message : 'shopify sync failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncAllShopifyTenants(opts?: {
|
||||
tenantId?: string;
|
||||
lookbackDays?: number;
|
||||
}): Promise<{ results: ShopifySyncResult[] }> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) return { results: [] };
|
||||
|
||||
const tRes = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,slug,name&agency_followed=neq.false`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) throw new Error(await tRes.text());
|
||||
let tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}[];
|
||||
if (opts?.tenantId) {
|
||||
tenants = tenants.filter((t) => t.id === opts.tenantId);
|
||||
}
|
||||
|
||||
const iRes = await fetch(
|
||||
`${base}/rest/v1/integrations?provider=eq.shopify&select=tenant_id,status,meta,external_account_id`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!iRes.ok) throw new Error(await iRes.text());
|
||||
const ints = (await iRes.json()) as {
|
||||
tenant_id: string;
|
||||
status?: string;
|
||||
meta?: {
|
||||
access_token?: string;
|
||||
api_token?: string;
|
||||
shop?: string;
|
||||
} | null;
|
||||
external_account_id?: string | null;
|
||||
}[];
|
||||
const byTenant = new Map(ints.map((i) => [i.tenant_id, i]));
|
||||
|
||||
const results: ShopifySyncResult[] = [];
|
||||
for (const t of tenants) {
|
||||
const integ = byTenant.get(t.id);
|
||||
if (!integ) continue;
|
||||
if (integ.status !== 'connected' && integ.status !== 'pending') continue;
|
||||
const token =
|
||||
(integ.meta && typeof integ.meta.access_token === 'string'
|
||||
? integ.meta.access_token
|
||||
: null) ||
|
||||
(integ.meta && typeof integ.meta.api_token === 'string'
|
||||
? integ.meta.api_token
|
||||
: null);
|
||||
const shop =
|
||||
integ.external_account_id ||
|
||||
(integ.meta && typeof integ.meta.shop === 'string' ? integ.meta.shop : null);
|
||||
if (!token || !shop) {
|
||||
results.push({
|
||||
tenantId: t.id,
|
||||
tenantSlug: t.slug,
|
||||
orders: 0,
|
||||
error: `Shopify sin shop/token en meta (${t.slug})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.push(
|
||||
await syncShopifyTenant({
|
||||
tenantId: t.id,
|
||||
slug: t.slug,
|
||||
shop,
|
||||
accessToken: token,
|
||||
lookbackDays: opts?.lookbackDays,
|
||||
})
|
||||
);
|
||||
}
|
||||
return { results };
|
||||
}
|
||||
Reference in New Issue
Block a user