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/superhuman-sync` — Seguimiento / Google Docs
|
||||||
- `/api/cron/clientify-sync` — reconciliación CRM Clientify (upsert 60d; diario 05:15 UTC)
|
- `/api/cron/clientify-sync` — reconciliación CRM Clientify (upsert 60d; diario 05:15 UTC)
|
||||||
(script: `python3 scripts/dokploy_ensure_clientify_sync_cron.py`)
|
(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
|
- `/api/cron/pipeline-publish` — piezas `aprobado` + `scheduled_at` vencida → Meta IG/FB
|
||||||
(cada ~10 min; script: `python3 scripts/dokploy_ensure_pipeline_publish_cron.py`)
|
(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'],
|
['search_console', 'Search Console'],
|
||||||
['tag_manager', 'Tag Manager'],
|
['tag_manager', 'Tag Manager'],
|
||||||
['clientify', 'Clientify'],
|
['clientify', 'Clientify'],
|
||||||
|
['gohighlevel', 'GoHighLevel'],
|
||||||
['klaviyo', 'Klaviyo'],
|
['klaviyo', 'Klaviyo'],
|
||||||
['mailchimp', 'Mailchimp'],
|
['mailchimp', 'Mailchimp'],
|
||||||
['google_drive', 'Google Drive'],
|
['google_drive', 'Google Drive'],
|
||||||
['wordpress', 'WordPress'],
|
['wordpress', 'WordPress'],
|
||||||
['prestashop', 'PrestaShop'],
|
['prestashop', 'PrestaShop'],
|
||||||
['shopify', 'Shopify'],
|
['shopify', 'Shopify'],
|
||||||
|
['woocommerce', 'WooCommerce'],
|
||||||
['instagram', 'Instagram'],
|
['instagram', 'Instagram'],
|
||||||
['facebook', 'Facebook Page'],
|
['facebook', 'Facebook Page'],
|
||||||
['tiktok', 'TikTok'],
|
['tiktok', 'TikTok'],
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ import {
|
|||||||
supabaseUrl,
|
supabaseUrl,
|
||||||
userRole,
|
userRole,
|
||||||
} from '@/lib/agency';
|
} from '@/lib/agency';
|
||||||
|
import { portfolioSpendWeightedCac } from '@/lib/metrics';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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). */
|
/** GET portfolio: tenants + integration summary + unread alerts (agency only). */
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
@@ -32,23 +39,33 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const base = supabaseUrl();
|
const base = supabaseUrl();
|
||||||
const [tenantsRes, integRes, alertsRes] = await Promise.all([
|
const since30 = dayOffset(-30);
|
||||||
fetch(
|
const [tenantsRes, integRes, alertsRes, spendRes, dealsRes] =
|
||||||
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
|
await Promise.all([
|
||||||
{
|
fetch(
|
||||||
headers,
|
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
|
||||||
cache: 'no-store',
|
{
|
||||||
}
|
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/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
|
||||||
fetch(
|
{ headers, cache: 'no-store' }
|
||||||
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
|
),
|
||||||
{ 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() : [];
|
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
|
||||||
// Fallback if agency_followed column missing
|
// Fallback if agency_followed column missing
|
||||||
@@ -70,6 +87,34 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
const alerts = alertsRes.ok ? await alertsRes.json() : [];
|
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 = {
|
type Integ = {
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -106,11 +151,19 @@ export async function GET() {
|
|||||||
for (const p of INTEGRATION_PROVIDERS) {
|
for (const p of INTEGRATION_PROVIDERS) {
|
||||||
byProvider[p.id] = rows.find((r) => r.provider === p.id);
|
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 {
|
return {
|
||||||
...t,
|
...t,
|
||||||
agency_followed: t.agency_followed !== false,
|
agency_followed: t.agency_followed !== false,
|
||||||
integrations: rows,
|
integrations: rows,
|
||||||
byProvider,
|
byProvider,
|
||||||
|
economics: {
|
||||||
|
spend30,
|
||||||
|
deals30,
|
||||||
|
cac30,
|
||||||
|
},
|
||||||
stats: {
|
stats: {
|
||||||
connected,
|
connected,
|
||||||
errors,
|
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({
|
return NextResponse.json({
|
||||||
portfolio,
|
portfolio,
|
||||||
alerts,
|
alerts,
|
||||||
providers: INTEGRATION_PROVIDERS,
|
providers: INTEGRATION_PROVIDERS,
|
||||||
schemaReady: !integMissing,
|
schemaReady: !integMissing,
|
||||||
|
portfolioCac30: portfolioCac,
|
||||||
|
economicsWindowDays: 30,
|
||||||
warning: integMissing
|
warning: integMissing
|
||||||
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
|
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { resolveActiveTenant } from '@/lib/tenant-context';
|
|||||||
import { syncAdsForTenants } from '@/lib/ads-sync';
|
import { syncAdsForTenants } from '@/lib/ads-sync';
|
||||||
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
||||||
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
|
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
|
||||||
|
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
|
||||||
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
import { syncAllConnectedOrganic } from '@/lib/google-organic';
|
||||||
import {
|
import {
|
||||||
getAgencyOAuthStatus,
|
getAgencyOAuthStatus,
|
||||||
@@ -68,14 +69,26 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (want('crm') || want('metrics')) {
|
if (want('crm') || want('metrics')) {
|
||||||
const [clientify, gohighlevel] = await Promise.all([
|
const [clientify, gohighlevel, shopify] = await Promise.all([
|
||||||
syncAllClientifyTenants({ tenantId }),
|
syncAllClientifyTenants({ tenantId }),
|
||||||
syncAllGhlTenants({ tenantId }),
|
syncAllGhlTenants({ tenantId }),
|
||||||
|
syncAllShopifyTenants({ tenantId }),
|
||||||
]);
|
]);
|
||||||
out.crm = {
|
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,
|
clientify,
|
||||||
gohighlevel,
|
gohighlevel,
|
||||||
|
shopify,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from '@/lib/superhuman-docs';
|
} from '@/lib/superhuman-docs';
|
||||||
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
|
||||||
import { syncAllGhlTenants } from '@/lib/gohighlevel-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';
|
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
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 {
|
try {
|
||||||
const [clientify, gohighlevel] = await Promise.all([
|
const lookback = Math.max(days * 2, 30);
|
||||||
syncAllClientifyTenants({ lookbackDays: Math.max(days * 2, 30) }),
|
const [clientify, gohighlevel, shopify] = await Promise.all([
|
||||||
syncAllGhlTenants(),
|
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 = {
|
out.crm = {
|
||||||
ok: true,
|
ok: true,
|
||||||
synced: results.filter((r) => !r.error).length,
|
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(
|
const failed = ['organic', 'ads', 'superhuman', 'crm'].some(
|
||||||
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
|
(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
|
includeTenantId: boolean
|
||||||
): Promise<CrmLeadRow[]> {
|
): Promise<CrmLeadRow[]> {
|
||||||
const select = includeTenantId
|
const select = includeTenantId
|
||||||
? 'tenant_id,source,created_at,status,updated_at,status_changed_at'
|
? 'id,tenant_id,source,created_at,status,updated_at,status_changed_at'
|
||||||
: 'source,created_at,status,updated_at,status_changed_at';
|
: 'id,source,created_at,status,updated_at,status_changed_at';
|
||||||
const selectFallback = includeTenantId
|
const selectFallback = includeTenantId
|
||||||
? 'tenant_id,source,created_at,status'
|
? 'id,tenant_id,source,created_at,status'
|
||||||
: 'source,created_at,status';
|
: 'id,source,created_at,status';
|
||||||
const pageSize = 1000;
|
const pageSize = 1000;
|
||||||
const out: CrmLeadRow[] = [];
|
const out: CrmLeadRow[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ type PortfolioRow = {
|
|||||||
agency_followed?: boolean;
|
agency_followed?: boolean;
|
||||||
agency_notes?: string | null;
|
agency_notes?: string | null;
|
||||||
byProvider?: Record<string, IntegCell | undefined>;
|
byProvider?: Record<string, IntegCell | undefined>;
|
||||||
|
economics?: {
|
||||||
|
spend30: number;
|
||||||
|
deals30: number;
|
||||||
|
cac30: number;
|
||||||
|
};
|
||||||
stats: {
|
stats: {
|
||||||
connected: number;
|
connected: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
@@ -120,6 +125,7 @@ export default function AgencyPortfolioBoard() {
|
|||||||
const [providers, setProviders] = useState<Provider[]>([]);
|
const [providers, setProviders] = useState<Provider[]>([]);
|
||||||
const [alerts, setAlerts] = useState<AlertRow[]>([]);
|
const [alerts, setAlerts] = useState<AlertRow[]>([]);
|
||||||
const [warning, setWarning] = useState<string | null>(null);
|
const [warning, setWarning] = useState<string | null>(null);
|
||||||
|
const [portfolioCac30, setPortfolioCac30] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
@@ -132,6 +138,7 @@ export default function AgencyPortfolioBoard() {
|
|||||||
setProviders(data.providers || []);
|
setProviders(data.providers || []);
|
||||||
setAlerts((data.alerts || []).filter((a: AlertRow) => !a.read_at));
|
setAlerts((data.alerts || []).filter((a: AlertRow) => !a.read_at));
|
||||||
setWarning(data.warning || null);
|
setWarning(data.warning || null);
|
||||||
|
setPortfolioCac30(Number(data.portfolioCac30 || 0));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +199,19 @@ export default function AgencyPortfolioBoard() {
|
|||||||
</p>
|
</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 && (
|
{alerts.length > 0 && (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
|
<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">
|
<p className="text-[11px] text-gray-500 truncate">
|
||||||
{t.slug} · {t.stats.connected}/
|
{t.slug} · {t.stats.connected}/
|
||||||
{t.stats.totalProviders} conectadas
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ const PLACEHOLDERS: Record<string, string> = {
|
|||||||
google_drive: 'URL de carpeta Drive o folder id',
|
google_drive: 'URL de carpeta Drive o folder id',
|
||||||
wordpress: 'https://sitio.com + Application Password',
|
wordpress: 'https://sitio.com + Application Password',
|
||||||
prestashop: 'https://tienda.com + API key',
|
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',
|
instagram: '@handle o Instagram Business Account ID',
|
||||||
facebook: 'Page ID / facebook.com/…',
|
facebook: 'Page ID / facebook.com/…',
|
||||||
tiktok: '@handle o Advertiser / Business ID',
|
tiktok: '@handle o Advertiser / Business ID',
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
buildCrmChannelStats,
|
buildCrmChannelStats,
|
||||||
buildCrmFunnel,
|
buildCrmFunnel,
|
||||||
buildCrmStatusBreakdown,
|
buildCrmStatusBreakdown,
|
||||||
|
DEFAULT_COHORT_WINDOW_DAYS,
|
||||||
|
cohortWonDeals,
|
||||||
filterDailyByDayRange,
|
filterDailyByDayRange,
|
||||||
filterDealsForRange,
|
filterDealsForRange,
|
||||||
filterLeadsByDayRange,
|
filterLeadsByDayRange,
|
||||||
@@ -115,6 +117,21 @@ export default function CrmDashboard({
|
|||||||
const cacGlobal =
|
const cacGlobal =
|
||||||
ventasTotal > 0 && spendTotal > 0 ? spendTotal / ventasTotal : 0;
|
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 cacByChannel = useMemo(() => {
|
||||||
const m = new Map<
|
const m = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -210,9 +227,15 @@ export default function CrmDashboard({
|
|||||||
source=google (hay que mapear la fuente en el CRM).
|
source=google (hay que mapear la fuente en el CRM).
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Ventas / CAC</strong> usan deals won del CRM. Un pedido
|
<strong>Ventas / CAC caja</strong> = deals won con <em>won_at</em> en
|
||||||
WooCommerce suele entrar como <em>ecommerce</em>, aunque el clic haya
|
el periodo. Un pedido WooCommerce suele entrar como{' '}
|
||||||
sido de Google Brand — sin UTM/gclid no se reasigna a Google.
|
<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>
|
</p>
|
||||||
</HelpDetails>
|
</HelpDetails>
|
||||||
|
|
||||||
@@ -232,7 +255,7 @@ export default function CrmDashboard({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<HeroBand>
|
<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="Leads" value={formatNum(funnel.total)} />
|
||||||
<HeroStat
|
<HeroStat
|
||||||
label="Clientes"
|
label="Clientes"
|
||||||
@@ -248,7 +271,7 @@ export default function CrmDashboard({
|
|||||||
value={`${funnel.clienteRate.toFixed(1)}%`}
|
value={`${funnel.clienteRate.toFixed(1)}%`}
|
||||||
/>
|
/>
|
||||||
<HeroStat
|
<HeroStat
|
||||||
label="CAC global"
|
label="CAC caja"
|
||||||
value={cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
|
value={cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
|
||||||
chip={
|
chip={
|
||||||
<span className="text-[11px] text-gray-500">
|
<span className="text-[11px] text-gray-500">
|
||||||
@@ -258,6 +281,17 @@ export default function CrmDashboard({
|
|||||||
</span>
|
</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
|
<HeroStat
|
||||||
label="Perdidos"
|
label="Perdidos"
|
||||||
value={formatNum(funnel.leadPerdido)}
|
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: 'google_drive', label: 'Google Drive', group: 'files', live: true },
|
||||||
{ id: 'wordpress', label: 'WordPress', group: 'cms', live: false },
|
{ id: 'wordpress', label: 'WordPress', group: 'cms', live: false },
|
||||||
{ id: 'prestashop', label: 'PrestaShop', 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: 'instagram', label: 'Instagram', group: 'social', live: true },
|
||||||
{ id: 'facebook', label: 'Facebook Page', group: 'social', live: true },
|
{ id: 'facebook', label: 'Facebook Page', group: 'social', live: true },
|
||||||
{ id: 'tiktok', label: 'TikTok', group: 'social', live: false },
|
{ id: 'tiktok', label: 'TikTok', group: 'social', live: false },
|
||||||
|
|||||||
@@ -147,14 +147,40 @@ export type GhlSyncResult = {
|
|||||||
tenantSlug: string;
|
tenantSlug: string;
|
||||||
leads: number;
|
leads: number;
|
||||||
deals: number;
|
deals: number;
|
||||||
|
lookbackDays?: number;
|
||||||
error?: string;
|
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: {
|
export async function syncGhlTenant(opts: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
pit?: string | null;
|
pit?: string | null;
|
||||||
locationId?: string | null;
|
locationId?: string | null;
|
||||||
|
/** Kept for API parity with Clientify; GHL search is full upsert (no wipe). */
|
||||||
|
lookbackDays?: number;
|
||||||
}): Promise<GhlSyncResult> {
|
}): Promise<GhlSyncResult> {
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
const base = supabaseUrl();
|
const base = supabaseUrl();
|
||||||
@@ -192,17 +218,32 @@ export async function syncGhlTenant(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tid = opts.tenantId;
|
const tid = opts.tenantId;
|
||||||
|
const lookbackDays = opts.lookbackDays ?? 60;
|
||||||
try {
|
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 contacts = await listContacts(token, locationId);
|
||||||
const uniqueContacts = [
|
const uniqueContacts = [
|
||||||
...new Map(contacts.map((c) => [String(c.id), c])).values(),
|
...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 leadRows = uniqueContacts.map((c) => {
|
||||||
const cid = String(c.id);
|
const cid = String(c.id);
|
||||||
const lid = uuid5(`ghl-lead:${tid}:${cid}`);
|
const lid = uuid5(`ghl-lead:${tid}:${cid}`);
|
||||||
@@ -238,22 +279,16 @@ export async function syncGhlTenant(opts: {
|
|||||||
created_at: parseDate(c.dateAdded) || new Date().toISOString(),
|
created_at: parseDate(c.dateAdded) || new Date().toISOString(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
for (const row of leadRows) {
|
||||||
for (let i = 0; i < leadRows.length; i += 200) {
|
const cid = String(row.crm_contact_id || '');
|
||||||
const chunk = leadRows.slice(i, i + 200);
|
if (cid) contactToLead.set(cid, String(row.id));
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await upsertChunks(
|
||||||
|
`${base}/rest/v1/leads`,
|
||||||
|
headers,
|
||||||
|
leadRows,
|
||||||
|
'upsert ghl leads'
|
||||||
|
);
|
||||||
|
|
||||||
const [opps, pipeNames] = await Promise.all([
|
const [opps, pipeNames] = await Promise.all([
|
||||||
listOpportunities(token, locationId),
|
listOpportunities(token, locationId),
|
||||||
@@ -262,10 +297,6 @@ export async function syncGhlTenant(opts: {
|
|||||||
const uniqueOpps = [
|
const uniqueOpps = [
|
||||||
...new Map(opps.map((d) => [String(d.id), d])).values(),
|
...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 dealRows = uniqueOpps.map((d) => {
|
||||||
const did = String(d.id);
|
const did = String(d.id);
|
||||||
@@ -294,22 +325,12 @@ export async function syncGhlTenant(opts: {
|
|||||||
name: d.name ? String(d.name).slice(0, 200) : null,
|
name: d.name ? String(d.name).slice(0, 200) : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
await upsertChunks(
|
||||||
for (let i = 0; i < dealRows.length; i += 200) {
|
`${base}/rest/v1/deals`,
|
||||||
const chunk = dealRows.slice(i, i + 200);
|
headers,
|
||||||
const r = await fetch(`${base}/rest/v1/deals`, {
|
dealRows,
|
||||||
method: 'POST',
|
'upsert ghl deals'
|
||||||
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)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
await fetch(
|
await fetch(
|
||||||
@@ -319,7 +340,7 @@ export async function syncGhlTenant(opts: {
|
|||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Prefer: 'return=minimal,resolution=merge-duplicates',
|
Prefer: 'return=minimal',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
@@ -327,6 +348,13 @@ export async function syncGhlTenant(opts: {
|
|||||||
error_message: null,
|
error_message: null,
|
||||||
external_account_id: locationId,
|
external_account_id: locationId,
|
||||||
label: 'GoHighLevel',
|
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,
|
tenantSlug: opts.slug,
|
||||||
leads: leadRows.length,
|
leads: leadRows.length,
|
||||||
deals: dealRows.length,
|
deals: dealRows.length,
|
||||||
|
lookbackDays,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await fetch(
|
await fetch(
|
||||||
@@ -365,6 +394,7 @@ export async function syncGhlTenant(opts: {
|
|||||||
|
|
||||||
export async function syncAllGhlTenants(opts?: {
|
export async function syncAllGhlTenants(opts?: {
|
||||||
tenantId?: string;
|
tenantId?: string;
|
||||||
|
lookbackDays?: number;
|
||||||
}): Promise<{ results: GhlSyncResult[] }> {
|
}): Promise<{ results: GhlSyncResult[] }> {
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
const base = supabaseUrl();
|
const base = supabaseUrl();
|
||||||
@@ -424,6 +454,7 @@ export async function syncAllGhlTenants(opts?: {
|
|||||||
slug: t.slug,
|
slug: t.slug,
|
||||||
pit,
|
pit,
|
||||||
locationId,
|
locationId,
|
||||||
|
lookbackDays: opts?.lookbackDays,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ export type MetricTotals = {
|
|||||||
cpl: number;
|
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 = {
|
export type Delta = {
|
||||||
wow: number | null;
|
wow: number | null;
|
||||||
mom: 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.
|
* 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.
|
* 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. */
|
/** CRM lead row used for channel attribution cross. */
|
||||||
export type CrmLeadRow = {
|
export type CrmLeadRow = {
|
||||||
|
id?: string | null;
|
||||||
source?: string | null;
|
source?: string | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
/** Last status change (→ cliente). Prefer for “ventas en periodo”. */
|
/** 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