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>
246 lines
7.1 KiB
TypeScript
246 lines
7.1 KiB
TypeScript
/** 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;
|
|
}
|