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:
4Ventures
2026-10-08 12:28:38 +02:00
parent 4baf256750
commit 71110915af
19 changed files with 1177 additions and 76 deletions

View File

@@ -52,12 +52,14 @@ export async function POST(req: NextRequest) {
['search_console', 'Search Console'],
['tag_manager', 'Tag Manager'],
['clientify', 'Clientify'],
['gohighlevel', 'GoHighLevel'],
['klaviyo', 'Klaviyo'],
['mailchimp', 'Mailchimp'],
['google_drive', 'Google Drive'],
['wordpress', 'WordPress'],
['prestashop', 'PrestaShop'],
['shopify', 'Shopify'],
['woocommerce', 'WooCommerce'],
['instagram', 'Instagram'],
['facebook', 'Facebook Page'],
['tiktok', 'TikTok'],

View File

@@ -7,9 +7,16 @@ import {
supabaseUrl,
userRole,
} from '@/lib/agency';
import { portfolioSpendWeightedCac } from '@/lib/metrics';
export const dynamic = 'force-dynamic';
function dayOffset(days: number): string {
const d = new Date();
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
/** GET portfolio: tenants + integration summary + unread alerts (agency only). */
export async function GET() {
const supabase = await createClient();
@@ -32,23 +39,33 @@ export async function GET() {
}
const base = supabaseUrl();
const [tenantsRes, integRes, alertsRes] = await Promise.all([
fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
{
headers,
cache: 'no-store',
}
),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
{ headers, cache: 'no-store' }
),
]);
const since30 = dayOffset(-30);
const [tenantsRes, integRes, alertsRes, spendRes, dealsRes] =
await Promise.all([
fetch(
`${base}/rest/v1/tenants?select=id,name,slug,created_at,agency_followed,agency_notes&order=name.asc`,
{
headers,
cache: 'no-store',
}
),
fetch(
`${base}/rest/v1/integrations?select=id,tenant_id,provider,status,label,external_account_id,last_sync_at,error_message,enabled,meta`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/agency_alerts?select=*&read_at=is.null&order=created_at.desc&limit=40`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/ad_daily?select=tenant_id,spend&date=gte.${since30}&limit=20000`,
{ headers, cache: 'no-store' }
),
fetch(
`${base}/rest/v1/deals?select=tenant_id,amount,stage,won_at&stage=eq.won&won_at=gte.${since30}&limit=10000`,
{ headers, cache: 'no-store' }
),
]);
let tenants = tenantsRes.ok ? await tenantsRes.json() : [];
// Fallback if agency_followed column missing
@@ -70,6 +87,34 @@ export async function GET() {
}
const alerts = alertsRes.ok ? await alertsRes.json() : [];
const spendByTenant = new Map<string, number>();
if (spendRes.ok) {
for (const row of (await spendRes.json()) as {
tenant_id?: string;
spend?: number;
}[]) {
if (!row.tenant_id) continue;
spendByTenant.set(
row.tenant_id,
(spendByTenant.get(row.tenant_id) || 0) + Number(row.spend || 0)
);
}
}
const dealsByTenant = new Map<string, number>();
if (dealsRes.ok) {
for (const row of (await dealsRes.json()) as {
tenant_id?: string;
amount?: number;
}[]) {
if (!row.tenant_id) continue;
if (!(Number(row.amount || 0) > 0)) continue;
dealsByTenant.set(
row.tenant_id,
(dealsByTenant.get(row.tenant_id) || 0) + 1
);
}
}
type Integ = {
tenant_id: string;
provider: string;
@@ -106,11 +151,19 @@ export async function GET() {
for (const p of INTEGRATION_PROVIDERS) {
byProvider[p.id] = rows.find((r) => r.provider === p.id);
}
const spend30 = spendByTenant.get(t.id) || 0;
const deals30 = dealsByTenant.get(t.id) || 0;
const cac30 = deals30 > 0 ? spend30 / deals30 : 0;
return {
...t,
agency_followed: t.agency_followed !== false,
integrations: rows,
byProvider,
economics: {
spend30,
deals30,
cac30,
},
stats: {
connected,
errors,
@@ -129,11 +182,20 @@ export async function GET() {
};
});
const portfolioCac = portfolioSpendWeightedCac(
portfolio.map((p) => ({
spend: p.economics.spend30,
deals: p.economics.deals30,
}))
);
return NextResponse.json({
portfolio,
alerts,
providers: INTEGRATION_PROVIDERS,
schemaReady: !integMissing,
portfolioCac30: portfolioCac,
economicsWindowDays: 30,
warning: integMissing
? 'Falta aplicar sql/agency_portfolio.sql en Supabase'
: null,

View File

@@ -5,6 +5,7 @@ import { resolveActiveTenant } from '@/lib/tenant-context';
import { syncAdsForTenants } from '@/lib/ads-sync';
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
import { syncAllConnectedOrganic } from '@/lib/google-organic';
import {
getAgencyOAuthStatus,
@@ -68,14 +69,26 @@ export async function POST(req: NextRequest) {
try {
if (want('crm') || want('metrics')) {
const [clientify, gohighlevel] = await Promise.all([
const [clientify, gohighlevel, shopify] = await Promise.all([
syncAllClientifyTenants({ tenantId }),
syncAllGhlTenants({ tenantId }),
syncAllShopifyTenants({ tenantId }),
]);
out.crm = {
results: [...clientify.results, ...gohighlevel.results],
results: [
...clientify.results,
...gohighlevel.results,
...shopify.results.map((r) => ({
tenantId: r.tenantId,
tenantSlug: r.tenantSlug,
leads: 0,
deals: r.orders,
error: r.error,
})),
],
clientify,
gohighlevel,
shopify,
};
}
} catch (e) {