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;
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
319
src/lib/shopify-sync.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user