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:
245
src/lib/agency-health.ts
Normal file
245
src/lib/agency-health.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/** Agency health checks → agency_alerts (sync stale, ecommerce volume drop). */
|
||||
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
const STALE_HOURS = 26;
|
||||
const ECOM_DROP_RATIO = 0.4; // alert if this week < 40% of prior week
|
||||
const ECOM_MIN_PRIOR = 3;
|
||||
|
||||
type AlertDraft = {
|
||||
tenant_id: string | null;
|
||||
severity: 'info' | 'warning' | 'critical';
|
||||
source: string;
|
||||
title: string;
|
||||
body: string;
|
||||
href?: string | null;
|
||||
};
|
||||
|
||||
function hoursAgo(iso: string | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const t = new Date(iso).getTime();
|
||||
if (Number.isNaN(t)) return null;
|
||||
return (Date.now() - t) / 36e5;
|
||||
}
|
||||
|
||||
function dayOffset(days: number): string {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function existingUnreadTitle(
|
||||
headers: Record<string, string>,
|
||||
base: string,
|
||||
title: string,
|
||||
tenantId: string | null
|
||||
): Promise<boolean> {
|
||||
const since = new Date(Date.now() - 36e5 * 36).toISOString();
|
||||
let q = `${base}/rest/v1/agency_alerts?select=id&title=eq.${encodeURIComponent(title)}&read_at=is.null&created_at=gte.${since}&limit=1`;
|
||||
if (tenantId) {
|
||||
q += `&tenant_id=eq.${encodeURIComponent(tenantId)}`;
|
||||
} else {
|
||||
q += `&tenant_id=is.null`;
|
||||
}
|
||||
const r = await fetch(q, { headers, cache: 'no-store' });
|
||||
if (!r.ok) return false;
|
||||
const rows = (await r.json()) as unknown[];
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function insertAlert(
|
||||
headers: Record<string, string>,
|
||||
base: string,
|
||||
draft: AlertDraft
|
||||
): Promise<boolean> {
|
||||
if (await existingUnreadTitle(headers, base, draft.title, draft.tenant_id)) {
|
||||
return false;
|
||||
}
|
||||
const r = await fetch(`${base}/rest/v1/agency_alerts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tenant_id: draft.tenant_id,
|
||||
severity: draft.severity,
|
||||
source: draft.source,
|
||||
title: draft.title,
|
||||
body: draft.body,
|
||||
href: draft.href || null,
|
||||
}),
|
||||
});
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
const CRM_PROVIDERS = ['clientify', 'gohighlevel'] as const;
|
||||
const DATA_PROVIDERS = [
|
||||
'clientify',
|
||||
'gohighlevel',
|
||||
'meta_ads',
|
||||
'google_ads',
|
||||
'ga4',
|
||||
'search_console',
|
||||
'shopify',
|
||||
'woocommerce',
|
||||
] as const;
|
||||
|
||||
function isEcommerceDealName(name: string | null | undefined): boolean {
|
||||
return /^(order|pedido)\s+\d+/i.test(String(name || ''));
|
||||
}
|
||||
|
||||
export type HealthAlertResult = {
|
||||
checked: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
details: string[];
|
||||
};
|
||||
|
||||
/** Run health checks and write unread agency_alerts (deduped 36h). */
|
||||
export async function runAgencyHealthAlerts(): Promise<HealthAlertResult> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
const out: HealthAlertResult = {
|
||||
checked: 0,
|
||||
created: 0,
|
||||
skipped: 0,
|
||||
details: [],
|
||||
};
|
||||
if (!headers || !base) {
|
||||
out.details.push('no service role');
|
||||
return out;
|
||||
}
|
||||
|
||||
const [tRes, iRes] = await Promise.all([
|
||||
fetch(
|
||||
`${base}/rest/v1/tenants?select=id,slug,name&agency_followed=neq.false`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
fetch(
|
||||
`${base}/rest/v1/integrations?select=tenant_id,provider,status,last_sync_at,enabled,error_message&enabled=neq.false`,
|
||||
{ headers, cache: 'no-store' }
|
||||
),
|
||||
]);
|
||||
if (!tRes.ok || !iRes.ok) {
|
||||
out.details.push('fetch tenants/integrations failed');
|
||||
return out;
|
||||
}
|
||||
|
||||
const tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}[];
|
||||
const integrations = (await iRes.json()) as {
|
||||
tenant_id: string;
|
||||
provider: string;
|
||||
status?: string;
|
||||
last_sync_at?: string | null;
|
||||
error_message?: string | null;
|
||||
}[];
|
||||
|
||||
const byTenant = new Map<string, typeof integrations>();
|
||||
for (const row of integrations) {
|
||||
const list = byTenant.get(row.tenant_id) || [];
|
||||
list.push(row);
|
||||
byTenant.set(row.tenant_id, list);
|
||||
}
|
||||
|
||||
const thisWeekStart = dayOffset(-7);
|
||||
const thisWeekEnd = dayOffset(0);
|
||||
const priorStart = dayOffset(-14);
|
||||
const priorEnd = dayOffset(-7);
|
||||
|
||||
for (const t of tenants) {
|
||||
out.checked += 1;
|
||||
const rows = byTenant.get(t.id) || [];
|
||||
|
||||
for (const row of rows) {
|
||||
if (!DATA_PROVIDERS.includes(row.provider as (typeof DATA_PROVIDERS)[number])) {
|
||||
continue;
|
||||
}
|
||||
if (row.status === 'error') {
|
||||
const title = `Integración en error: ${row.provider} · ${t.name}`;
|
||||
const ok = await insertAlert(headers, base, {
|
||||
tenant_id: t.id,
|
||||
severity: 'critical',
|
||||
source: 'health',
|
||||
title,
|
||||
body: row.error_message || 'Revisa credenciales / sync en ficha cliente.',
|
||||
href: `/dashboard/agencia/cliente/${t.id}`,
|
||||
});
|
||||
if (ok) {
|
||||
out.created += 1;
|
||||
out.details.push(title);
|
||||
} else out.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (row.status !== 'connected') continue;
|
||||
const age = hoursAgo(row.last_sync_at);
|
||||
if (age == null || age > STALE_HOURS) {
|
||||
const title = `Sync atrasado: ${row.provider} · ${t.name}`;
|
||||
const ok = await insertAlert(headers, base, {
|
||||
tenant_id: t.id,
|
||||
severity: CRM_PROVIDERS.includes(
|
||||
row.provider as (typeof CRM_PROVIDERS)[number]
|
||||
)
|
||||
? 'critical'
|
||||
: 'warning',
|
||||
source: 'health',
|
||||
title,
|
||||
body:
|
||||
age == null
|
||||
? 'Conectado pero sin last_sync_at. Lanza sync o revisa el cron.'
|
||||
: `Último sync hace ${Math.round(age)}h (umbral ${STALE_HOURS}h).`,
|
||||
href: `/dashboard/agencia/cliente/${t.id}`,
|
||||
});
|
||||
if (ok) {
|
||||
out.created += 1;
|
||||
out.details.push(title);
|
||||
} else out.skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Ecommerce volume: Order/Pedido won deals this week vs prior
|
||||
const dealsRes = await fetch(
|
||||
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(t.id)}&stage=eq.won&won_at=gte.${priorStart}&won_at=lt.${thisWeekEnd}&select=name,won_at,amount&limit=2000`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!dealsRes.ok) continue;
|
||||
const deals = (await dealsRes.json()) as {
|
||||
name?: string | null;
|
||||
won_at?: string | null;
|
||||
amount?: number | null;
|
||||
}[];
|
||||
const ecom = deals.filter(
|
||||
(d) => isEcommerceDealName(d.name) && Number(d.amount || 0) > 0
|
||||
);
|
||||
const thisW = ecom.filter((d) => {
|
||||
const day = (d.won_at || '').slice(0, 10);
|
||||
return day >= thisWeekStart && day < thisWeekEnd;
|
||||
}).length;
|
||||
const priorW = ecom.filter((d) => {
|
||||
const day = (d.won_at || '').slice(0, 10);
|
||||
return day >= priorStart && day < priorEnd;
|
||||
}).length;
|
||||
if (priorW >= ECOM_MIN_PRIOR && thisW < priorW * ECOM_DROP_RATIO) {
|
||||
const title = `Caída ventas ecommerce · ${t.name}`;
|
||||
const ok = await insertAlert(headers, base, {
|
||||
tenant_id: t.id,
|
||||
severity: 'warning',
|
||||
source: 'ecommerce-health',
|
||||
title,
|
||||
body: `Pedidos CRM (Order/Pedido) esta semana: ${thisW} vs ${priorW} la anterior. Posible fallo Woo→Clientify/Shopify.`,
|
||||
href: `/dashboard/crm`,
|
||||
});
|
||||
if (ok) {
|
||||
out.created += 1;
|
||||
out.details.push(title);
|
||||
} else out.skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user