/** 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, 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; }