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

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