feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal

Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite
histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-28 10:23:36 +02:00
parent dd50251de2
commit cf27eba74d
30 changed files with 2367 additions and 514 deletions

View File

@@ -20,7 +20,7 @@ export async function POST(req: NextRequest) {
const tenantId =
typeof body.tenantId === 'string' ? body.tenantId : undefined;
const days =
typeof body.days === 'number' && body.days > 0 && body.days <= 90
typeof body.days === 'number' && body.days > 0 && body.days <= 1000
? body.days
: 30;

View File

@@ -7,14 +7,8 @@ import {
userRole,
} from '@/lib/agency';
import {
SUPERHUMAN_PROVIDER,
bestDocForTenant,
buildSeguimientoRowsForDoc,
listDocs,
matchDocsToTenants,
patchSuperhumanIntegration,
syncAllSuperhumanTenants,
superhumanConfigured,
upsertSeguimientoRows,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
@@ -51,6 +45,9 @@ export async function GET() {
if ('error' in gate && gate.error) return gate.error;
try {
const { listDocs, matchDocsToTenants, SUPERHUMAN_PROVIDER } = await import(
'@/lib/superhuman-docs'
);
const headers = (gate as { headers: { apikey: string; Authorization: string } })
.headers;
const [docs, tenantsRes] = await Promise.all([
@@ -91,8 +88,6 @@ export async function GET() {
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const headers = (gate as { headers: { apikey: string; Authorization: string } })
.headers;
const body = await req.json().catch(() => ({}));
const onlyTenant: string | undefined = body.tenantId
@@ -102,128 +97,14 @@ export async function POST(req: NextRequest) {
const maxEntries = Math.min(Number(body.maxEntries) || 80, 150);
try {
const docs = await listDocs();
const tRes = await fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
);
if (!tRes.ok) {
return NextResponse.json({ error: await tRes.text() }, { status: 500 });
}
let tenants = (await tRes.json()) as {
id: string;
name: string;
slug: string;
}[];
if (onlyTenant) {
tenants = tenants.filter((t) => t.id === onlyTenant);
}
const results: {
tenantId: string;
tenantName: string;
docId: string | null;
docName: string | null;
upserted: number;
status: string;
error?: string;
}[] = [];
for (const tenant of tenants) {
let docId: string | null = null;
let docName: string | null = null;
// Prefer already-linked integration
const integUrl = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${SUPERHUMAN_PROVIDER}&select=external_account_id,meta,label`;
const integR = await fetch(integUrl, { headers, cache: 'no-store' });
const integRows = integR.ok
? ((await integR.json()) as {
external_account_id?: string | null;
meta?: { doc_name?: string };
}[])
: [];
if (integRows[0]?.external_account_id) {
docId = integRows[0].external_account_id;
docName =
integRows[0].meta?.doc_name ||
docs.find((d) => d.id === docId)?.name ||
null;
} else {
const hit = bestDocForTenant(tenant, docs);
if (hit) {
docId = hit.doc.id;
docName = hit.doc.name;
}
}
if (!docId) {
await patchSuperhumanIntegration(tenant.id, {
status: 'disconnected',
error_message: 'Sin doc Superhuman matched por nombre',
});
results.push({
tenantId: tenant.id,
tenantName: tenant.name,
docId: null,
docName: null,
upserted: 0,
status: 'unmatched',
});
continue;
}
try {
const rows = await buildSeguimientoRowsForDoc({
tenantId: tenant.id,
tenantName: tenant.name,
docId,
maxEntries,
exportBodies,
});
const upserted = await upsertSeguimientoRows(rows);
await patchSuperhumanIntegration(tenant.id, {
status: 'connected',
external_account_id: docId,
label: docName || 'Superhuman Docs',
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
doc_name: docName,
entries: upserted,
synced_at: new Date().toISOString(),
},
});
results.push({
tenantId: tenant.id,
tenantName: tenant.name,
docId,
docName,
upserted,
status: 'synced',
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'sync failed';
await patchSuperhumanIntegration(tenant.id, {
status: 'error',
external_account_id: docId,
error_message: msg.slice(0, 400),
});
results.push({
tenantId: tenant.id,
tenantName: tenant.name,
docId,
docName,
upserted: 0,
status: 'error',
error: msg,
});
}
}
const result = await syncAllSuperhumanTenants({
tenantId: onlyTenant || null,
maxEntries,
exportBodies,
});
return NextResponse.json({
ok: true,
results,
synced: results.filter((r) => r.status === 'synced').length,
...result,
});
} catch (e) {
return NextResponse.json(

View File

@@ -310,7 +310,7 @@ export async function POST(req: NextRequest) {
`${i + 1}. **${c.campaign_name}** (${c.platform.toUpperCase()}):\n - Inversión: **${Number(c.spend || 0).toFixed(2)} €**\n - Clics: ${c.clicks || 0} (CTR: ${c.impressions > 0 ? ((c.clicks / c.impressions) * 100).toFixed(2) : 0}%)\n - Leads Captados: **${c.leads || 0}**`
).join('\n\n');
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: Datos diarios procesados vía Windsor.ai.*`);
return reply(`🏆 **Top Campañas por Inversión Publicitaria:**\n\n${list}\n\n*Fuente: ad_daily (Meta + Google Ads sincronizados en el portal).*`);
} else {
return reply('📢 Actualmente no constan campañas activas con inversión registrada en los últimos 30 días para este portal.');
}
@@ -347,7 +347,7 @@ export async function POST(req: NextRequest) {
`📄 **${m.document_title}** (${m.document_type.toUpperCase()}):\n> ${m.chunk_text.replace(/\n/g, '\n> ')}`
).join('\n\n');
return reply(`📚 **Fragmentos Recuperados de la Biblioteca de Activos (RAG Híbrido):**\n\n${excerpts}\n\n*Búsqueda híbrida vectorial (pgvector) y BM25 en document_chunks.*`);
return reply(`📚 **Fragmentos de la Biblioteca de Activos:**\n\n${excerpts}\n\n*Fuente: document_chunks del tenant (búsqueda en portal).*`);
}
}

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAdsForTenants } from '@/lib/ads-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily Meta + Google Ads → ad_daily.
* Auth: Authorization: Bearer $CRON_SECRET
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const days = Math.min(
90,
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
);
try {
const { results, days: d } = await syncAdsForTenants({ days });
const ok = results.filter((r) => r.metaRows + r.googleRows > 0).length;
const withErrors = results.filter((r) => r.errors.length).length;
return NextResponse.json({
ok: true,
days: d,
synced: ok,
withErrors,
results,
at: new Date().toISOString(),
});
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllConnectedOrganic } from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
import { syncAdsForTenants } from '@/lib/ads-sync';
import {
syncAllSuperhumanTenants,
superhumanConfigured,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Master daily job: organic + ads + superhuman.
* Dokploy (daily ~06:00 UTC):
* curl -sS -H "Authorization: Bearer $CRON_SECRET" \
* "https://app.4ventures.es/api/cron/daily-sync"
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
const days = Math.min(
28,
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)
);
const out: Record<string, unknown> = {
ok: true,
days,
at: new Date().toISOString(),
};
// 1) Organic
try {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) {
out.organic = { ok: false, warning: 'Google agencia no conectado' };
} else {
out.organic = {
ok: true,
...(await syncAllConnectedOrganic({ days })),
};
}
} catch (e) {
out.organic = {
ok: false,
error: e instanceof Error ? e.message : 'organic failed',
};
}
// 2) Ads
try {
const ads = await syncAdsForTenants({ days });
out.ads = {
ok: true,
synced: ads.results.filter((r) => r.metaRows + r.googleRows > 0).length,
withErrors: ads.results.filter((r) => r.errors.length).length,
results: ads.results,
};
} catch (e) {
out.ads = {
ok: false,
error: e instanceof Error ? e.message : 'ads failed',
};
}
// 3) Superhuman
try {
if (!superhumanConfigured()) {
out.superhuman = {
ok: false,
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
};
} else {
out.superhuman = {
ok: true,
...(await syncAllSuperhumanTenants({
maxEntries: 80,
exportBodies: true,
})),
};
}
} catch (e) {
out.superhuman = {
ok: false,
error: e instanceof Error ? e.message : 'superhuman failed',
};
}
const failed = ['organic', 'ads', 'superhuman'].some(
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
);
return NextResponse.json(out, { status: failed ? 207 : 200 });
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import { syncAllConnectedOrganic } from '@/lib/google-organic';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
@@ -6,20 +7,12 @@ export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily (or any schedule) organic sync for all connected tenants.
* Daily organic sync for all connected tenants.
* Auth: Authorization: Bearer $CRON_SECRET OR ?secret=$CRON_SECRET
* Dokploy cron example (daily 06:15 UTC):
* curl -sS -H "Authorization: Bearer $CRON_SECRET" https://app.4ventures.es/api/cron/organic-sync
*/
export async function GET(req: NextRequest) {
const secret = process.env.CRON_SECRET?.trim();
const auth = req.headers.get('authorization') || '';
const q = req.nextUrl.searchParams.get('secret') || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : q;
if (!secret || token !== secret) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const denied = assertCronAuth(req);
if (denied) return denied;
const status = await getAgencyOAuthStatus();
if (!status.connected) {
@@ -29,7 +22,6 @@ export async function GET(req: NextRequest) {
});
}
// Rolling 7 days is enough daily; full history already filled by first sync
const days = Math.min(
28,
Math.max(3, Number(req.nextUrl.searchParams.get('days')) || 7)

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { assertCronAuth } from '@/lib/cron-auth';
import {
syncAllSuperhumanTenants,
superhumanConfigured,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
/**
* Daily Superhuman / Seguimiento sync for all tenants.
* Auth: Authorization: Bearer $CRON_SECRET
*/
export async function GET(req: NextRequest) {
const denied = assertCronAuth(req);
if (denied) return denied;
if (!superhumanConfigured()) {
return NextResponse.json({
ok: false,
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
});
}
try {
const result = await syncAllSuperhumanTenants({
maxEntries: 100,
exportBodies: true,
});
return NextResponse.json({
ok: true,
...result,
at: new Date().toISOString(),
});
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : 'sync failed' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return GET(req);
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
export const dynamic = 'force-dynamic';
@@ -18,6 +18,73 @@ type Integ = {
meta?: Record<string, unknown> | null;
};
async function cacheSocialSnapshot(
tenantId: string,
provider: 'facebook' | 'instagram',
payload: unknown
) {
const headers = serviceHeaders();
if (!headers) return;
const now = new Date().toISOString();
// Merge into meta without wiping other keys: read-modify-write
const r = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}&select=meta&limit=1`,
{ headers, cache: 'no-store' }
);
let meta: Record<string, unknown> = {};
if (r.ok) {
const rows = (await r.json()) as { meta?: Record<string, unknown> }[];
meta = { ...(rows[0]?.meta || {}) };
}
// Keep a compact snapshot (metrics + up to 12 posts)
const snap =
payload && typeof payload === 'object'
? {
...(payload as object),
posts: Array.isArray((payload as { posts?: unknown }).posts)
? (payload as { posts: unknown[] }).posts.slice(0, 12)
: undefined,
}
: payload;
meta.social_cache = snap;
meta.social_cached_at = now;
await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
meta,
last_sync_at: now,
error_message: null,
}),
}
);
}
function readSocialCache(integrations: Integ[]): {
facebook?: unknown;
instagram?: unknown;
at?: string;
} {
const out: { facebook?: unknown; instagram?: unknown; at?: string } = {};
for (const i of integrations) {
const cache = i.meta?.social_cache;
const at = typeof i.meta?.social_cached_at === 'string'
? i.meta.social_cached_at
: i.last_sync_at || undefined;
if (!cache) continue;
if (i.provider === 'facebook') out.facebook = cache;
if (i.provider === 'instagram') out.instagram = cache;
if (at && (!out.at || at > out.at)) out.at = at;
}
return out;
}
type SocialPost = {
id: string;
platform: 'facebook' | 'instagram';
@@ -585,25 +652,34 @@ export async function GET(req: NextRequest) {
const posts: SocialPost[] = [];
const tokens = await resolveMetaAccessTokens();
const fbInteg = integrations.find(
(i) =>
i.provider === 'facebook' &&
i.status === 'connected' &&
i.external_account_id &&
!i.external_account_id.startsWith('http')
);
const igInteg = integrations.find(
(i) =>
i.provider === 'instagram' &&
i.status === 'connected' &&
i.external_account_id &&
!i.external_account_id.startsWith('http')
);
if (!tokens.length) {
metrics.warning =
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
const cached = readSocialCache(integrations);
if (cached.facebook) metrics.facebook = cached.facebook;
if (cached.instagram) metrics.instagram = cached.instagram;
if (cached.facebook || cached.instagram) {
metrics.cached = true;
metrics.cachedAt = cached.at;
metrics.warning +=
' Mostrando último snapshot cacheado.';
}
} else {
const fbInteg = integrations.find(
(i) =>
i.provider === 'facebook' &&
i.status === 'connected' &&
i.external_account_id &&
!i.external_account_id.startsWith('http')
);
const igInteg = integrations.find(
(i) =>
i.provider === 'instagram' &&
i.status === 'connected' &&
i.external_account_id &&
!i.external_account_id.startsWith('http')
);
const token =
(await pickWorkingToken(tokens, fbInteg?.external_account_id)) ||
(await pickWorkingToken(tokens, igInteg?.external_account_id)) ||
@@ -621,6 +697,7 @@ export async function GET(req: NextRequest) {
if ('posts' in fb && Array.isArray(fb.posts)) {
posts.push(...(fb.posts as SocialPost[]));
}
await cacheSocialSnapshot(ctx.tenantId, 'facebook', fb);
}
if (igInteg?.external_account_id) {
const ig = await fetchIgBundle(
@@ -633,9 +710,18 @@ export async function GET(req: NextRequest) {
if ('posts' in ig && Array.isArray(ig.posts)) {
posts.push(...(ig.posts as SocialPost[]));
}
await cacheSocialSnapshot(ctx.tenantId, 'instagram', ig);
}
} catch (e) {
metrics.warning = e instanceof Error ? e.message : 'metrics error';
const cached = readSocialCache(integrations);
if (cached.facebook && !metrics.facebook) metrics.facebook = cached.facebook;
if (cached.instagram && !metrics.instagram)
metrics.instagram = cached.instagram;
if (cached.facebook || cached.instagram) {
metrics.cached = true;
metrics.cachedAt = cached.at;
}
}
}

View File

@@ -110,8 +110,13 @@ async function resolveTenantId(slugOrId: string | null): Promise<string | null>
const base = supabaseUrl();
// UUID?
if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId;
// Legacy slug aliases (renames)
const SLUG_ALIASES: Record<string, string> = {
herencias: 'fbm-abogados',
};
const slug = SLUG_ALIASES[slugOrId.toLowerCase()] || slugOrId;
const r = await fetch(
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slugOrId)}&select=id&limit=1`,
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id&limit=1`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return null;
@@ -130,7 +135,59 @@ async function upsertLead(tenantId: string, c: Json) {
str(c.channel, 80) ||
str(c.medium, 80) ||
'clientify';
const row = {
const newStatus = (str(c.status, 40) || 'new').toLowerCase();
const now = new Date().toISOString();
const base = supabaseUrl();
// Preserve alta date across webhook re-writes (DELETE+INSERT).
let prevCreated: string | null = null;
let prevStatus: string | null = null;
let prevStatusChanged: string | null = null;
const prevR = await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status,status_changed_at&limit=1`,
{ headers, cache: 'no-store' }
);
if (prevR.ok) {
const rows = (await prevR.json()) as {
created_at?: string;
status?: string;
status_changed_at?: string | null;
}[];
if (rows[0]) {
prevCreated = rows[0].created_at || null;
prevStatus = (rows[0].status || '').toLowerCase() || null;
prevStatusChanged = rows[0].status_changed_at || null;
}
} else {
// status_changed_at may not exist yet — retry without it
const prevR2 = await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=created_at,status&limit=1`,
{ headers, cache: 'no-store' }
);
if (prevR2.ok) {
const rows = (await prevR2.json()) as {
created_at?: string;
status?: string;
}[];
if (rows[0]) {
prevCreated = rows[0].created_at || null;
prevStatus = (rows[0].status || '').toLowerCase() || null;
}
}
}
const fromCfy =
str(c.created, 40) ||
str(c.created_at, 40) ||
str(c.date_created, 40);
let createdAt = prevCreated || fromCfy || now;
// Normalize date-only
if (createdAt.length === 10) createdAt = `${createdAt}T12:00:00.000Z`;
const statusChanged =
!prevStatus || prevStatus !== newStatus ? now : prevStatusChanged || createdAt;
const row: Record<string, unknown> = {
id,
tenant_id: tenantId,
first_name:
@@ -142,19 +199,45 @@ async function upsertLead(tenantId: string, c: Json) {
medium: str(c.medium, 80),
crm: 'clientify',
crm_contact_id: cid,
status: (str(c.status, 40) || 'new').toLowerCase(),
status: newStatus,
created_at: createdAt,
updated_at: now,
status_changed_at: statusChanged,
};
// Prefer upsert on crm_contact_id if unique; else delete+insert
const base = supabaseUrl();
await fetch(
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}`,
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
);
const ins = await fetch(`${base}/rest/v1/leads`, {
let ins = await fetch(`${base}/rest/v1/leads`, {
method: 'POST',
headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' },
headers: {
...headers,
Prefer: 'return=minimal',
'Content-Type': 'application/json',
},
body: JSON.stringify(row),
});
// Schema not migrated yet: drop timing columns and retry
if (!ins.ok) {
const err = await ins.text();
if (err.includes('updated_at') || err.includes('status_changed_at')) {
delete row.updated_at;
delete row.status_changed_at;
ins = await fetch(`${base}/rest/v1/leads`, {
method: 'POST',
headers: {
...headers,
Prefer: 'return=minimal',
'Content-Type': 'application/json',
},
body: JSON.stringify(row),
});
} else {
throw new Error(`lead insert ${ins.status}: ${err.slice(0, 200)}`);
}
}
if (!ins.ok) {
throw new Error(`lead insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
}
@@ -218,7 +301,7 @@ export async function GET(req: NextRequest) {
service: 'clientify-webhook',
tenant: tenant || null,
hint: 'POST events from Clientify. Use ?tenant=<slug>',
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia',
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=<slug>&secret=…',
});
}
@@ -226,7 +309,17 @@ export async function POST(req: NextRequest) {
const secretEnv = process.env.CLIENTIFY_WEBHOOK_SECRET?.trim();
const secretQ = req.nextUrl.searchParams.get('secret');
const secretH = req.headers.get('x-clientify-secret');
if (secretEnv && secretQ !== secretEnv && secretH !== secretEnv) {
// Obligatorio: sin secreto el webhook es inyectable desde fuera
if (!secretEnv) {
return NextResponse.json(
{
error: 'webhook_not_configured',
hint: 'Define CLIENTIFY_WEBHOOK_SECRET en Dokploy',
},
{ status: 503 }
);
}
if (secretQ !== secretEnv && secretH !== secretEnv) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
@@ -238,7 +331,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json(
{
error: 'missing_or_unknown_tenant',
hint: 'Add ?tenant=agua-miia (slug) to the webhook URL',
hint: 'Add ?tenant=<slug>&secret=… to the webhook URL',
},
{ status: 400 }
);
@@ -260,10 +353,12 @@ export async function POST(req: NextRequest) {
try {
if (entity === 'contact') {
const r = await upsertLead(tenantId, payload);
await markClientifyConnected(tenantId, tenantParam);
return NextResponse.json({ ok: true, entity, ...r });
}
if (entity === 'deal') {
const r = await upsertDeal(tenantId, payload);
await markClientifyConnected(tenantId, tenantParam);
return NextResponse.json({ ok: true, entity, ...r });
}
// company/activity/budget/product: acknowledge for now
@@ -279,3 +374,52 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: msg }, { status: 500 });
}
}
/** Webhook escribe leads pero la ficha quedaba "disconnected" — alinear status. */
async function markClientifyConnected(
tenantId: string,
slugHint: string | null
) {
const headers = serviceHeaders();
if (!headers) return;
const base = supabaseUrl();
const now = new Date().toISOString();
const body = {
status: 'connected',
enabled: true,
last_sync_at: now,
error_message: null,
label: 'Clientify',
external_account_id: slugHint || undefined,
meta: {
source: 'webhook',
synced_at: now,
},
};
const patch = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.clientify`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(body),
}
);
if (patch.ok) return;
await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=ignore-duplicates,return=minimal',
},
body: JSON.stringify({
tenant_id: tenantId,
provider: 'clientify',
...body,
}),
});
}

View File

@@ -15,6 +15,9 @@ async function fetchCrmLeads(
includeTenantId: boolean
): Promise<CrmLeadRow[]> {
const select = includeTenantId
? 'tenant_id,source,created_at,status,updated_at,status_changed_at'
: 'source,created_at,status,updated_at,status_changed_at';
const selectFallback = includeTenantId
? 'tenant_id,source,created_at,status'
: 'source,created_at,status';
const pageSize = 1000;
@@ -27,12 +30,20 @@ async function fetchCrmLeads(
const tidFilter = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: '';
let useSelect = select;
for (let offset = 0; offset < 80000; offset += pageSize) {
const r = await fetch(
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
`${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
{ headers, cache: 'no-store' }
);
if (!r.ok) break;
if (!r.ok) {
if (useSelect !== selectFallback && offset === 0) {
useSelect = selectFallback;
offset = -pageSize;
continue;
}
break;
}
const chunk = (await r.json()) as CrmLeadRow[];
out.push(...chunk);
if (chunk.length < pageSize) break;
@@ -42,12 +53,24 @@ async function fetchCrmLeads(
const supabase = await createClient();
for (let offset = 0; offset < 80000; offset += pageSize) {
const { data } = await supabase
let q = supabase
.from('leads')
.select(select)
.eq('crm', 'clientify')
.order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1);
let { data, error } = await q;
if (error && offset === 0) {
const retry = await supabase
.from('leads')
.select(selectFallback)
.eq('crm', 'clientify')
.order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1);
data = retry.data;
error = retry.error;
}
if (error) break;
const chunk = (data as CrmLeadRow[]) || [];
out.push(...chunk);
if (chunk.length < pageSize) break;

View File

@@ -12,6 +12,7 @@ import {
buildComparisons,
mergeDealsIntoWeekly,
resolveWeeklyRows,
resolveWonCohort,
sumDaily,
sumWeekly,
} from '@/lib/metrics';
@@ -19,12 +20,13 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc
export const dynamic = 'force-dynamic';
/** Paginate CRM leads (source + created_at) for attribution cross. */
/** Paginate CRM leads (source + created_at + status) for attribution + cohort. */
async function fetchCrmLeads(
tenantId: string | null,
useServiceRole: boolean
): Promise<CrmLeadRow[]> {
const select = 'source,created_at';
const select = 'source,created_at,status,tenant_id,updated_at,status_changed_at';
const selectFallback = 'source,created_at,status,tenant_id';
const pageSize = 1000;
const out: CrmLeadRow[] = [];
@@ -35,12 +37,20 @@ async function fetchCrmLeads(
const tidFilter = tenantId
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
: '';
let useSelect = select;
for (let offset = 0; offset < 50000; offset += pageSize) {
const r = await fetch(
`${base}/rest/v1/leads?select=${select}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
`${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`,
{ headers, cache: 'no-store' }
);
if (!r.ok) break;
if (!r.ok) {
if (useSelect !== selectFallback && offset === 0) {
useSelect = selectFallback;
offset = -pageSize;
continue;
}
break;
}
const chunk = (await r.json()) as CrmLeadRow[];
out.push(...chunk);
if (chunk.length < pageSize) break;
@@ -50,12 +60,23 @@ async function fetchCrmLeads(
const supabase = await createClient();
for (let offset = 0; offset < 50000; offset += pageSize) {
const { data } = await supabase
let { data, error } = await supabase
.from('leads')
.select(select)
.eq('crm', 'clientify')
.order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1);
if (error && offset === 0) {
const retry = await supabase
.from('leads')
.select(selectFallback)
.eq('crm', 'clientify')
.order('created_at', { ascending: false })
.range(offset, offset + pageSize - 1);
data = retry.data;
error = retry.error;
}
if (error) break;
const chunk = (data as CrmLeadRow[]) || [];
out.push(...chunk);
if (chunk.length < pageSize) break;
@@ -163,6 +184,9 @@ export default async function KpisPage() {
wonDeals = await fetchWonDeals(ctx.tenantId, false);
}
const cohort = resolveWonCohort(wonDeals, crmLeads);
wonDeals = cohort.deals;
weekly = mergeDealsIntoWeekly(resolveWeeklyRows(weekly, daily), wonDeals);
const comparisons = buildComparisons(weekly);
@@ -188,6 +212,7 @@ export default async function KpisPage() {
dailyRows={daily}
crmLeads={crmLeads}
wonDeals={wonDeals}
cohortSource={cohort.source}
/>
);
}