+
0 ? formatEurCents(cacGlobal) : '—'}
chip={
@@ -258,6 +281,17 @@ export default function CrmDashboard({
}
/>
+ 0 ? formatEurCents(cacCohort) : '—'}
+ chip={
+
+ {cohortDeals.length > 0
+ ? `${formatNum(cohortDeals.length)} cierres de altas del periodo`
+ : 'Sin cierres cohort (falta lead_id o ventana)'}
+
+ }
+ />
,
+ 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;
+}
diff --git a/src/lib/agency.ts b/src/lib/agency.ts
index 878d173..3741b96 100644
--- a/src/lib/agency.ts
+++ b/src/lib/agency.ts
@@ -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 },
diff --git a/src/lib/gohighlevel-sync.ts b/src/lib/gohighlevel-sync.ts
index 3bd4301..5863d52 100644
--- a/src/lib/gohighlevel-sync.ts
+++ b/src/lib/gohighlevel-sync.ts
@@ -147,14 +147,40 @@ export type GhlSyncResult = {
tenantSlug: string;
leads: number;
deals: number;
+ lookbackDays?: number;
error?: string;
};
+async function upsertChunks(
+ url: string,
+ headers: Record,
+ rows: Record[],
+ label: string
+): Promise {
+ 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 {
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();
+ 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();
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,
})
);
}
diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts
index 4101050..475663b 100644
--- a/src/lib/metrics.ts
+++ b/src/lib/metrics.ts
@@ -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();
+ 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”. */
diff --git a/src/lib/shopify-sync.ts b/src/lib/shopify-sync.ts
new file mode 100644
index 0000000..edfc413
--- /dev/null
+++ b/src/lib/shopify-sync.ts
@@ -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,
+ rows: Record[],
+ label: string
+): Promise {
+ 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 {
+ 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[] = [];
+ 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[] };
+ 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 };
+}