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:
4Ventures
2026-10-08 12:28:38 +02:00
parent 4baf256750
commit 71110915af
19 changed files with 1177 additions and 76 deletions

View File

@@ -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`)

View 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())

View 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;

View File

@@ -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'],

View File

@@ -7,9 +7,16 @@ import {
supabaseUrl,
userRole,
} from '@/lib/agency';
import { portfolioSpendWeightedCac } from '@/lib/metrics';
export const dynamic = 'force-dynamic';
function dayOffset(days: number): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
/** GET portfolio: tenants + integration summary + unread alerts (agency only). */
export async function GET() {
const supabase = await createClient();
@@ -32,23 +39,33 @@ export async function GET() {
}
const base = supabaseUrl();
const [tenantsRes, integRes, alertsRes] = await Promise.all([
fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
{
headers,
cache: 'no-store',
}
),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
{ headers, cache: 'no-store' }
),
]);
const since30 = dayOffset(-30);
const [tenantsRes, integRes, alertsRes, spendRes, dealsRes] =
await Promise.all([
fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
{
headers,
cache: 'no-store',
}
),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/ad_daily?select=tenant_id,spend&date=gte.${since30}&limit=20000`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/deals?select=tenant_id,amount,stage,won_at&stage=eq.won&won_at=gte.${since30}&limit=10000`,
{ headers, cache: 'no-store' }
),
]);
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
// Fallback if agency_followed column missing
@@ -70,6 +87,34 @@ export async function GET() {
}
const alerts = alertsRes.ok ? await alertsRes.json() : [];
const spendByTenant = new Map<string, number>();
if (spendRes.ok) {
for (const row of (await spendRes.json()) as {
tenant_id?: string;
spend?: number;
}[]) {
if (!row.tenant_id) continue;
spendByTenant.set(
row.tenant_id,
(spendByTenant.get(row.tenant_id) || 0) + Number(row.spend || 0)
);
}
}
const dealsByTenant = new Map<string, number>();
if (dealsRes.ok) {
for (const row of (await dealsRes.json()) as {
tenant_id?: string;
amount?: number;
}[]) {
if (!row.tenant_id) continue;
if (!(Number(row.amount || 0) > 0)) continue;
dealsByTenant.set(
row.tenant_id,
(dealsByTenant.get(row.tenant_id) || 0) + 1
);
}
}
type Integ = {
tenant_id: string;
provider: string;
@@ -106,11 +151,19 @@ export async function GET() {
for (const p of INTEGRATION_PROVIDERS) {
byProvider[p.id] = rows.find((r) => r.provider === p.id);
}
const spend30 = spendByTenant.get(t.id) || 0;
const deals30 = dealsByTenant.get(t.id) || 0;
const cac30 = deals30 > 0 ? spend30 / deals30 : 0;
return {
...t,
agency_followed: t.agency_followed !== false,
integrations: rows,
byProvider,
economics: {
spend30,
deals30,
cac30,
},
stats: {
connected,
errors,
@@ -129,11 +182,20 @@ export async function GET() {
};
});
const portfolioCac = portfolioSpendWeightedCac(
portfolio.map((p) => ({
spend: p.economics.spend30,
deals: p.economics.deals30,
}))
);
return NextResponse.json({
portfolio,
alerts,
providers: INTEGRATION_PROVIDERS,
schemaReady: !integMissing,
portfolioCac30: portfolioCac,
economicsWindowDays: 30,
warning: integMissing
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
: null,

View File

@@ -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) {

View File

@@ -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
);

View 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);
}

View 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);
}

View 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);
}

View File

@@ -16,11 +16,11 @@ async function fetchCrmLeads(
includeTenantId: boolean
): Promise<CrmLeadRow[]> {
const select = includeTenantId
? 'tenant_id,source,created_at,status,updated_at,status_changed_at'
: 'source,created_at,status,updated_at,status_changed_at';
? 'id,tenant_id,source,created_at,status,updated_at,status_changed_at'
: 'id,source,created_at,status,updated_at,status_changed_at';
const selectFallback = includeTenantId
? 'tenant_id,source,created_at,status'
: 'source,created_at,status';
? 'id,tenant_id,source,created_at,status'
: 'id,source,created_at,status';
const pageSize = 1000;
const out: CrmLeadRow[] = [];

View File

@@ -34,6 +34,11 @@ type PortfolioRow = {
agency_followed?: boolean;
agency_notes?: string | null;
byProvider?: Record<string, IntegCell | undefined>;
economics?: {
spend30: number;
deals30: number;
cac30: number;
};
stats: {
connected: number;
errors: number;
@@ -120,6 +125,7 @@ export default function AgencyPortfolioBoard() {
const [providers, setProviders] = useState<Provider[]>([]);
const [alerts, setAlerts] = useState<AlertRow[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const [portfolioCac30, setPortfolioCac30] = useState(0);
const [loading, setLoading] = useState(true);
const [showArchived, setShowArchived] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
@@ -132,6 +138,7 @@ export default function AgencyPortfolioBoard() {
setProviders(data.providers || []);
setAlerts((data.alerts || []).filter((a: AlertRow) => !a.read_at));
setWarning(data.warning || null);
setPortfolioCac30(Number(data.portfolioCac30 || 0));
setLoading(false);
}
@@ -192,6 +199,19 @@ export default function AgencyPortfolioBoard() {
</p>
)}
{portfolioCac30 > 0 && (
<p className="text-xs text-gray-600 border border-gray-200 rounded-lg px-3 py-2 bg-white">
CAC portafolio 30d (ponderado por spend):{' '}
<span className="font-semibold text-gray-900 tabular-nums">
{portfolioCac30.toLocaleString('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
})}
</span>
</p>
)}
{alerts.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-gray-900">
@@ -310,6 +330,13 @@ export default function AgencyPortfolioBoard() {
<p className="text-[11px] text-gray-500 truncate">
{t.slug} · {t.stats.connected}/
{t.stats.totalProviders} conectadas
{t.economics && t.economics.deals30 > 0
? ` · CAC 30d ${t.economics.cac30.toLocaleString('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
})} (${t.economics.deals30} ventas)`
: ''}
</p>
</div>

View File

@@ -31,7 +31,10 @@ const PLACEHOLDERS: Record<string, string> = {
google_drive: 'URL de carpeta Drive o folder id',
wordpress: 'https://sitio.com + Application Password',
prestashop: 'https://tienda.com + API key',
shopify: 'mitienda.myshopify.com + Admin API token',
shopify:
'Shop (mitienda.myshopify.com) en ID externo + Admin API token en meta.access_token',
woocommerce:
'Slot de salud Woo→CRM. Pedidos llegan vía Clientify; API Woo directa = siguiente fase',
instagram: '@handle o Instagram Business Account ID',
facebook: 'Page ID / facebook.com/…',
tiktok: '@handle o Advertiser / Business ID',

View File

@@ -19,6 +19,8 @@ import {
buildCrmChannelStats,
buildCrmFunnel,
buildCrmStatusBreakdown,
DEFAULT_COHORT_WINDOW_DAYS,
cohortWonDeals,
filterDailyByDayRange,
filterDealsForRange,
filterLeadsByDayRange,
@@ -115,6 +117,21 @@ export default function CrmDashboard({
const cacGlobal =
ventasTotal > 0 && spendTotal > 0 ? spendTotal / ventasTotal : 0;
const cohortDeals = useMemo(() => {
if (!since || !until) return [];
return cohortWonDeals({
leads: crmLeads,
deals: wonDeals,
since,
until,
windowDays: DEFAULT_COHORT_WINDOW_DAYS,
});
}, [crmLeads, wonDeals, since, until]);
const cacCohort =
cohortDeals.length > 0 && spendTotal > 0
? spendTotal / cohortDeals.length
: 0;
const cacByChannel = useMemo(() => {
const m = new Map<
string,
@@ -210,9 +227,15 @@ export default function CrmDashboard({
source=google (hay que mapear la fuente en el CRM).
</p>
<p>
<strong>Ventas / CAC</strong> usan deals won del CRM. Un pedido
WooCommerce suele entrar como <em>ecommerce</em>, aunque el clic haya
sido de Google Brand — sin UTM/gclid no se reasigna a Google.
<strong>Ventas / CAC caja</strong> = deals won con <em>won_at</em> en
el periodo. Un pedido WooCommerce suele entrar como{' '}
<em>ecommerce</em> sin UTM/gclid.
</p>
<p>
<strong>CAC cohort ({DEFAULT_COHORT_WINDOW_DAYS}d)</strong> = inversión
del periodo ÷ leads creados en el periodo que cerraron en ≤
{DEFAULT_COHORT_WINDOW_DAYS} días. Mejor para leer adquisición del
mes.
</p>
</HelpDetails>
@@ -232,7 +255,7 @@ export default function CrmDashboard({
) : null}
<HeroBand>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 md:gap-6">
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4 md:gap-6">
<HeroStat label="Leads" value={formatNum(funnel.total)} />
<HeroStat
label="Clientes"
@@ -248,7 +271,7 @@ export default function CrmDashboard({
value={`${funnel.clienteRate.toFixed(1)}%`}
/>
<HeroStat
label="CAC global"
label="CAC caja"
value={cacGlobal > 0 ? formatEurCents(cacGlobal) : '—'}
chip={
<span className="text-[11px] text-gray-500">
@@ -258,6 +281,17 @@ export default function CrmDashboard({
</span>
}
/>
<HeroStat
label={`CAC cohort ${DEFAULT_COHORT_WINDOW_DAYS}d`}
value={cacCohort > 0 ? formatEurCents(cacCohort) : '—'}
chip={
<span className="text-[11px] text-gray-500">
{cohortDeals.length > 0
? `${formatNum(cohortDeals.length)} cierres de altas del periodo`
: 'Sin cierres cohort (falta lead_id o ventana)'}
</span>
}
/>
<HeroStat
label="Perdidos"
value={formatNum(funnel.leadPerdido)}

245
src/lib/agency-health.ts Normal file
View 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;
}

View File

@@ -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 },

View File

@@ -147,14 +147,40 @@ export type GhlSyncResult = {
tenantSlug: string;
leads: number;
deals: number;
lookbackDays?: number;
error?: string;
};
async function upsertChunks(
url: string,
headers: Record<string, string>,
rows: Record<string, unknown>[],
label: string
): Promise<void> {
for (let i = 0; i < rows.length; i += 100) {
const chunk = rows.slice(i, i + 100);
const r = await fetch(url, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=minimal',
},
body: JSON.stringify(chunk),
});
if (!r.ok) {
throw new Error(`${label}: ${(await r.text()).slice(0, 220)}`);
}
}
}
export async function syncGhlTenant(opts: {
tenantId: string;
slug: string;
pit?: string | null;
locationId?: string | null;
/** Kept for API parity with Clientify; GHL search is full upsert (no wipe). */
lookbackDays?: number;
}): Promise<GhlSyncResult> {
const headers = serviceHeaders();
const base = supabaseUrl();
@@ -192,17 +218,32 @@ export async function syncGhlTenant(opts: {
}
const tid = opts.tenantId;
const lookbackDays = opts.lookbackDays ?? 60;
try {
// Existing leads for FK-safe deal linking
const contactToLead = new Map<string, string>();
for (let offset = 0; offset < 20000; offset += 1000) {
const lr = await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.gohighlevel&select=id,crm_contact_id&limit=1000&offset=${offset}`,
{ headers, cache: 'no-store' }
);
if (!lr.ok) break;
const rows = (await lr.json()) as {
id: string;
crm_contact_id?: string | null;
}[];
if (!rows.length) break;
for (const row of rows) {
if (row.crm_contact_id) contactToLead.set(row.crm_contact_id, row.id);
}
if (rows.length < 1000) break;
}
const contacts = await listContacts(token, locationId);
const uniqueContacts = [
...new Map(contacts.map((c) => [String(c.id), c])).values(),
];
await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.gohighlevel`,
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
);
const contactToLead = new Map<string, string>();
const leadRows = uniqueContacts.map((c) => {
const cid = String(c.id);
const lid = uuid5(`ghl-lead:${tid}:${cid}`);
@@ -238,22 +279,16 @@ export async function syncGhlTenant(opts: {
created_at: parseDate(c.dateAdded) || new Date().toISOString(),
};
});
for (let i = 0; i < leadRows.length; i += 200) {
const chunk = leadRows.slice(i, i + 200);
const r = await fetch(`${base}/rest/v1/leads`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal,resolution=merge-duplicates',
},
body: JSON.stringify(chunk),
});
if (!r.ok) {
throw new Error(`insert leads: ${(await r.text()).slice(0, 180)}`);
}
for (const row of leadRows) {
const cid = String(row.crm_contact_id || '');
if (cid) contactToLead.set(cid, String(row.id));
}
await upsertChunks(
`${base}/rest/v1/leads`,
headers,
leadRows,
'upsert ghl leads'
);
const [opps, pipeNames] = await Promise.all([
listOpportunities(token, locationId),
@@ -262,10 +297,6 @@ export async function syncGhlTenant(opts: {
const uniqueOpps = [
...new Map(opps.map((d) => [String(d.id), d])).values(),
];
await fetch(
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tid)}&crm_deal_id=like.ghl:*`,
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
);
const dealRows = uniqueOpps.map((d) => {
const did = String(d.id);
@@ -294,22 +325,12 @@ export async function syncGhlTenant(opts: {
name: d.name ? String(d.name).slice(0, 200) : null,
};
});
for (let i = 0; i < dealRows.length; i += 200) {
const chunk = dealRows.slice(i, i + 200);
const r = await fetch(`${base}/rest/v1/deals`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal,resolution=merge-duplicates',
},
body: JSON.stringify(chunk),
});
if (!r.ok) {
throw new Error(`insert deals: ${(await r.text()).slice(0, 180)}`);
}
}
await upsertChunks(
`${base}/rest/v1/deals`,
headers,
dealRows,
'upsert ghl deals'
);
const now = new Date().toISOString();
await fetch(
@@ -319,7 +340,7 @@ export async function syncGhlTenant(opts: {
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal,resolution=merge-duplicates',
Prefer: 'return=minimal',
},
body: JSON.stringify({
status: 'connected',
@@ -327,6 +348,13 @@ export async function syncGhlTenant(opts: {
error_message: null,
external_account_id: locationId,
label: 'GoHighLevel',
meta: {
source: 'cron',
synced_at: now,
lookback_days: lookbackDays,
leads_upserted: leadRows.length,
deals_upserted: dealRows.length,
},
}),
}
);
@@ -336,6 +364,7 @@ export async function syncGhlTenant(opts: {
tenantSlug: opts.slug,
leads: leadRows.length,
deals: dealRows.length,
lookbackDays,
};
} catch (e) {
await fetch(
@@ -365,6 +394,7 @@ export async function syncGhlTenant(opts: {
export async function syncAllGhlTenants(opts?: {
tenantId?: string;
lookbackDays?: number;
}): Promise<{ results: GhlSyncResult[] }> {
const headers = serviceHeaders();
const base = supabaseUrl();
@@ -424,6 +454,7 @@ export async function syncAllGhlTenants(opts?: {
slug: t.slug,
pit,
locationId,
lookbackDays: opts?.lookbackDays,
})
);
}

View File

@@ -49,6 +49,15 @@ export type MetricTotals = {
cpl: number;
};
/** Default attribution window for cohort CAC (days after lead create). */
export const DEFAULT_COHORT_WINDOW_DAYS = 60;
export type CohortCacMetrics = {
cac_cohort: number;
cohort_deals_won: number;
cohort_revenue_won: number;
};
export type Delta = {
wow: number | null;
mom: number | null;
@@ -623,6 +632,69 @@ export function withCohortFromDeals(
};
}
function addUtcDays(day: string, days: number): string {
const d = new Date(`${day}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
/**
* Cohort closes: leads created in [since, until] that won within
* `windowDays` of creation (default 60). Cash CAC stays on won_at.
*/
export function cohortWonDeals(opts: {
leads: CrmLeadRow[];
deals: DealWonRow[];
since: string;
until: string;
windowDays?: number;
}): DealWonRow[] {
const windowDays = opts.windowDays ?? DEFAULT_COHORT_WINDOW_DAYS;
const leadById = new Map<string, CrmLeadRow>();
for (const l of opts.leads) {
if (l.id) leadById.set(l.id, l);
}
const paid = preferOnlinePaidDeals(
opts.deals.filter((d) => !d.stage || d.stage === 'won')
);
return paid.filter((d) => {
const won = (d.won_at || '').slice(0, 10);
if (!won || !d.lead_id) return false;
const lead = leadById.get(d.lead_id);
const created = (lead?.created_at || '').slice(0, 10);
if (!created) return false;
if (created < opts.since || created > opts.until) return false;
const maxWon = addUtcDays(created, windowDays);
return won >= created && won <= maxWon;
});
}
export function withCohortCacMetrics(
spend: number,
cohortDeals: DealWonRow[]
): CohortCacMetrics {
const deals = cohortDeals.length;
const revenue = cohortDeals.reduce((a, d) => a + n(d.amount), 0);
return {
cac_cohort: deals > 0 ? spend / deals : 0,
cohort_deals_won: deals,
cohort_revenue_won: Math.round(revenue * 100) / 100,
};
}
/** Portfolio blended CAC: spend-weighted average of per-tenant cash CACs. */
export function portfolioSpendWeightedCac(
rows: { spend: number; deals: number }[]
): number {
let spend = 0;
let deals = 0;
for (const r of rows) {
spend += n(r.spend);
deals += n(r.deals);
}
return deals > 0 ? spend / deals : 0;
}
/**
* Inject won-deal cohort into weekly rows so WoW/MoM/YoY include CRM closes.
* Ads sync leaves cohort_* at 0; without this, CAC/ROAS deltas stay empty.
@@ -664,6 +736,7 @@ export function mergeDealsIntoWeekly(
/** CRM lead row used for channel attribution cross. */
export type CrmLeadRow = {
id?: string | null;
source?: string | null;
created_at?: string | null;
/** Last status change (→ cliente). Prefer for “ventas en periodo”. */

319
src/lib/shopify-sync.ts Normal file
View 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 };
}