diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 95160f3..2a64fd3 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -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`) diff --git a/scripts/dokploy_apply_woocommerce_sql.py b/scripts/dokploy_apply_woocommerce_sql.py new file mode 100644 index 0000000..4b71592 --- /dev/null +++ b/scripts/dokploy_apply_woocommerce_sql.py @@ -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()) diff --git a/sql/woocommerce_provider.sql b/sql/woocommerce_provider.sql new file mode 100644 index 0000000..68ed7b6 --- /dev/null +++ b/sql/woocommerce_provider.sql @@ -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; diff --git a/src/app/api/admin/create-client/route.ts b/src/app/api/admin/create-client/route.ts index 110e5ff..cacb75b 100644 --- a/src/app/api/admin/create-client/route.ts +++ b/src/app/api/admin/create-client/route.ts @@ -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'], diff --git a/src/app/api/admin/portfolio/route.ts b/src/app/api/admin/portfolio/route.ts index 1bd3915..2af741c 100644 --- a/src/app/api/admin/portfolio/route.ts +++ b/src/app/api/admin/portfolio/route.ts @@ -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(); + 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(); + 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, diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts index 2106257..1853845 100644 --- a/src/app/api/admin/sync/route.ts +++ b/src/app/api/admin/sync/route.ts @@ -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) { diff --git a/src/app/api/cron/daily-sync/route.ts b/src/app/api/cron/daily-sync/route.ts index 8398bf0..8a7fbf6 100644 --- a/src/app/api/cron/daily-sync/route.ts +++ b/src/app/api/cron/daily-sync/route.ts @@ -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 ); diff --git a/src/app/api/cron/ghl-sync/route.ts b/src/app/api/cron/ghl-sync/route.ts new file mode 100644 index 0000000..cf5a933 --- /dev/null +++ b/src/app/api/cron/ghl-sync/route.ts @@ -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); +} diff --git a/src/app/api/cron/health-alerts/route.ts b/src/app/api/cron/health-alerts/route.ts new file mode 100644 index 0000000..3964f11 --- /dev/null +++ b/src/app/api/cron/health-alerts/route.ts @@ -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); +} diff --git a/src/app/api/cron/shopify-sync/route.ts b/src/app/api/cron/shopify-sync/route.ts new file mode 100644 index 0000000..48c883d --- /dev/null +++ b/src/app/api/cron/shopify-sync/route.ts @@ -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); +} diff --git a/src/app/dashboard/crm/page.tsx b/src/app/dashboard/crm/page.tsx index f0b98b0..2edfceb 100644 --- a/src/app/dashboard/crm/page.tsx +++ b/src/app/dashboard/crm/page.tsx @@ -16,11 +16,11 @@ async function fetchCrmLeads( includeTenantId: boolean ): Promise { 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[] = []; diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx index 7631889..f432310 100644 --- a/src/components/AgencyPortfolioBoard.tsx +++ b/src/components/AgencyPortfolioBoard.tsx @@ -34,6 +34,11 @@ type PortfolioRow = { agency_followed?: boolean; agency_notes?: string | null; byProvider?: Record; + economics?: { + spend30: number; + deals30: number; + cac30: number; + }; stats: { connected: number; errors: number; @@ -120,6 +125,7 @@ export default function AgencyPortfolioBoard() { const [providers, setProviders] = useState([]); const [alerts, setAlerts] = useState([]); const [warning, setWarning] = useState(null); + const [portfolioCac30, setPortfolioCac30] = useState(0); const [loading, setLoading] = useState(true); const [showArchived, setShowArchived] = useState(false); const [busyId, setBusyId] = useState(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() {

)} + {portfolioCac30 > 0 && ( +

+ CAC portafolio 30d (ponderado por spend):{' '} + + {portfolioCac30.toLocaleString('es-ES', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 0, + })} + +

+ )} + {alerts.length > 0 && (
@@ -310,6 +330,13 @@ export default function AgencyPortfolioBoard() {

{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)` + : ''}

diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx index 44ad80c..553f83a 100644 --- a/src/components/ClientIntegrationsPanel.tsx +++ b/src/components/ClientIntegrationsPanel.tsx @@ -31,7 +31,10 @@ const PLACEHOLDERS: Record = { 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', diff --git a/src/components/CrmDashboard.tsx b/src/components/CrmDashboard.tsx index f00ba64..a057c5a 100644 --- a/src/components/CrmDashboard.tsx +++ b/src/components/CrmDashboard.tsx @@ -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).

- Ventas / CAC usan deals won del CRM. Un pedido - WooCommerce suele entrar como ecommerce, aunque el clic haya - sido de Google Brand — sin UTM/gclid no se reasigna a Google. + Ventas / CAC caja = deals won con won_at en + el periodo. Un pedido WooCommerce suele entrar como{' '} + ecommerce sin UTM/gclid. +

+

+ CAC cohort ({DEFAULT_COHORT_WINDOW_DAYS}d) = 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.

@@ -232,7 +255,7 @@ export default function CrmDashboard({ ) : null} -
+
0 ? formatEurCents(cacGlobal) : '—'} chip={ @@ -258,6 +281,17 @@ export default function CrmDashboard({ } /> + 0 ? formatEurCents(cacCohort) : '—'} + chip={ + + {cohortDeals.length > 0 + ? `${formatNum(cohortDeals.length)} cierres de altas del periodo` + : 'Sin cierres cohort (falta lead_id o ventana)'} + + } + /> , + base: string, + title: string, + tenantId: string | null +): Promise { + 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, + base: string, + draft: AlertDraft +): Promise { + 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 { + 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(); + 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; +} diff --git a/src/lib/agency.ts b/src/lib/agency.ts index 878d173..3741b96 100644 --- a/src/lib/agency.ts +++ b/src/lib/agency.ts @@ -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 }, diff --git a/src/lib/gohighlevel-sync.ts b/src/lib/gohighlevel-sync.ts index 3bd4301..5863d52 100644 --- a/src/lib/gohighlevel-sync.ts +++ b/src/lib/gohighlevel-sync.ts @@ -147,14 +147,40 @@ export type GhlSyncResult = { tenantSlug: string; leads: number; deals: number; + lookbackDays?: number; error?: string; }; +async function upsertChunks( + url: string, + headers: Record, + rows: Record[], + label: string +): Promise { + 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 { 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(); + 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(); 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, }) ); } diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 4101050..475663b 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -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(); + 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”. */ diff --git a/src/lib/shopify-sync.ts b/src/lib/shopify-sync.ts new file mode 100644 index 0000000..edfc413 --- /dev/null +++ b/src/lib/shopify-sync.ts @@ -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, + rows: Record[], + label: string +): Promise { + 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 { + 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[] = []; + 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[] }; + 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 }; +}