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:
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
51
src/app/api/cron/ghl-sync/route.ts
Normal file
51
src/app/api/cron/ghl-sync/route.ts
Normal 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);
|
||||
}
|
||||
38
src/app/api/cron/health-alerts/route.ts
Normal file
38
src/app/api/cron/health-alerts/route.ts
Normal 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);
|
||||
}
|
||||
52
src/app/api/cron/shopify-sync/route.ts
Normal file
52
src/app/api/cron/shopify-sync/route.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user