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) {

View File

@@ -9,6 +9,8 @@ import {
} from '@/lib/superhuman-docs';
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
import { runAgencyHealthAlerts } from '@/lib/agency-health';
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
export const dynamic = 'force-dynamic';
@@ -92,13 +94,25 @@ export async function GET(req: NextRequest) {
};
}
// 4) Clientify CRM → leads/deals (prefer dedicated /api/cron/clientify-sync)
// 4) CRM + Shopify (prefer dedicated crons; safety net here)
try {
const [clientify, gohighlevel] = await Promise.all([
syncAllClientifyTenants({ lookbackDays: Math.max(days * 2, 30) }),
syncAllGhlTenants(),
const lookback = Math.max(days * 2, 30);
const [clientify, gohighlevel, shopify] = await Promise.all([
syncAllClientifyTenants({ lookbackDays: lookback }),
syncAllGhlTenants({ lookbackDays: lookback }),
syncAllShopifyTenants({ lookbackDays: lookback }),
]);
const results = [...clientify.results, ...gohighlevel.results];
const results = [
...clientify.results,
...gohighlevel.results,
...shopify.results.map((r) => ({
tenantId: r.tenantId,
tenantSlug: r.tenantSlug,
leads: 0,
deals: r.orders,
error: r.error,
})),
];
out.crm = {
ok: true,
synced: results.filter((r) => !r.error).length,
@@ -122,6 +136,16 @@ export async function GET(req: NextRequest) {
};
}
// 6) Health alerts (stale sync / ecommerce drop)
try {
out.healthAlerts = await runAgencyHealthAlerts();
} catch (e) {
out.healthAlerts = {
ok: false,
error: e instanceof Error ? e.message : 'health-alerts failed',
};
}
const failed = ['organic', 'ads', 'superhuman', 'crm'].some(
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
);

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllGhlTenants } from '@/lib/gohighlevel-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily GoHighLevel reconciliation (upsert, no wipe).
* Dokploy ~05:20 UTC:
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/ghl-sync"
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const lookbackDays = Math.min(
120,
Math.max(14, Number(req.nextUrl.searchParams.get('lookback')) || 60)
);
const tenantId = req.nextUrl.searchParams.get('tenant_id') || undefined;
try {
const { results } = await syncAllGhlTenants({ tenantId, lookbackDays });
const withErrors = results.filter((r) => r.error);
return NextResponse.json(
{
ok: withErrors.length === 0,
at: new Date().toISOString(),
lookbackDays,
synced: results.filter((r) => !r.error).length,
withErrors: withErrors.length,
results,
},
{ status: withErrors.length ? 207 : 200 }
);
} catch (e) {
return NextResponse.json(
{
ok: false,
error: e instanceof Error ? e.message : 'ghl-sync failed',
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { runAgencyHealthAlerts } from '@/lib/agency-health';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
/**
* Daily agency health → agency_alerts (stale sync, ecommerce drop).
* Dokploy ~07:00 UTC:
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/health-alerts"
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
try {
const result = await runAgencyHealthAlerts();
return NextResponse.json({
ok: true,
at: new Date().toISOString(),
...result,
});
} catch (e) {
return NextResponse.json(
{
ok: false,
error: e instanceof Error ? e.message : 'health-alerts failed',
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllShopifyTenants } from '@/lib/shopify-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily Shopify paid-orders → deals.
* Requires integrations.meta.access_token + shop / external_account_id.
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const lookbackDays = Math.min(
120,
Math.max(14, Number(req.nextUrl.searchParams.get('lookback')) || 60)
);
const tenantId = req.nextUrl.searchParams.get('tenant_id') || undefined;
try {
const { results } = await syncAllShopifyTenants({
tenantId,
lookbackDays,
});
const withErrors = results.filter((r) => r.error);
return NextResponse.json(
{
ok: withErrors.length === 0,
at: new Date().toISOString(),
lookbackDays,
synced: results.filter((r) => !r.error).length,
withErrors: withErrors.length,
results,
},
{ status: withErrors.length ? 207 : 200 }
);
} catch (e) {
return NextResponse.json(
{
ok: false,
error: e instanceof Error ? e.message : 'shopify-sync failed',
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}