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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).*`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
src/app/api/cron/ads-sync/route.ts
Normal file
43
src/app/api/cron/ads-sync/route.ts
Normal 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);
|
||||
}
|
||||
100
src/app/api/cron/daily-sync/route.ts
Normal file
100
src/app/api/cron/daily-sync/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
46
src/app/api/cron/superhuman-sync/route.ts
Normal file
46
src/app/api/cron/superhuman-sync/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type Integ = {
|
||||
meta?: { folder_name?: string; match_score?: number } | null;
|
||||
};
|
||||
|
||||
type Provider = { id: string; label: string; group: string };
|
||||
type Provider = { id: string; label: string; group: string; live?: boolean };
|
||||
|
||||
const PLACEHOLDERS: Record<string, string> = {
|
||||
meta_ads: 'act_… (Ad Account ID)',
|
||||
@@ -219,6 +219,11 @@ export default function ClientIntegrationsPanel({
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{p.label}
|
||||
{p.live === false && (
|
||||
<span className="ml-2 text-[10px] font-normal text-gray-500">
|
||||
(sin conector API aún)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{folderHint && (
|
||||
<p className="text-[11px] text-brand-700 mt-0.5">
|
||||
|
||||
@@ -299,7 +299,7 @@ export default function CreativesPipeline() {
|
||||
setDraft((d) => ({ ...d, brand_name: e.target.value }))
|
||||
}
|
||||
className={inputCls}
|
||||
placeholder="Agua Miia"
|
||||
placeholder="Marca del cliente"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Canal">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
AdDailyRow,
|
||||
CrmChannelStats,
|
||||
CrmDateLens,
|
||||
CrmLeadRow,
|
||||
attributionCross,
|
||||
buildCrmChannelStats,
|
||||
@@ -32,6 +33,15 @@ function rateClass(rate: number): string {
|
||||
return 'text-gray-500';
|
||||
}
|
||||
|
||||
const LENS_HELP: Record<CrmDateLens, string> = {
|
||||
created:
|
||||
'Altas: leads cuya fecha de creación en Clientify cae en el periodo del header.',
|
||||
status:
|
||||
'Conversiones: usa la fecha de cambio de status (→ cliente). Requiere columnas status_changed_at (sql/leads_status_timing.sql).',
|
||||
snapshot:
|
||||
'Estado actual: ignora el periodo para el funnel; muestra todos los contactos como están ahora en el warehouse.',
|
||||
};
|
||||
|
||||
export default function CrmDashboard({
|
||||
label,
|
||||
portfolioWide,
|
||||
@@ -44,11 +54,19 @@ export default function CrmDashboard({
|
||||
dailyRows?: AdDailyRow[];
|
||||
}) {
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
const [lens, setLens] = useState<CrmDateLens>('created');
|
||||
|
||||
const hasStatusTiming = useMemo(
|
||||
() => crmLeads.some((l) => Boolean(l.status_changed_at || l.updated_at)),
|
||||
[crmLeads]
|
||||
);
|
||||
|
||||
const rangedLeads = useMemo(() => {
|
||||
if (!crmLeads.length) return [];
|
||||
return filterLeadsByDayRange(crmLeads, since, until);
|
||||
}, [crmLeads, since, until]);
|
||||
const effective: CrmDateLens =
|
||||
lens === 'status' && !hasStatusTiming ? 'created' : lens;
|
||||
return filterLeadsByDayRange(crmLeads, since, until, effective);
|
||||
}, [crmLeads, since, until, lens, hasStatusTiming]);
|
||||
|
||||
const rangedDaily = useMemo(() => {
|
||||
if (!dailyRows.length) return [];
|
||||
@@ -90,6 +108,43 @@ export default function CrmDashboard({
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-gray-600 shrink-0">
|
||||
Lente CRM
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(
|
||||
[
|
||||
['created', 'Altas en periodo'],
|
||||
['status', 'Conversiones en periodo'],
|
||||
['snapshot', 'Estado actual'],
|
||||
] as const
|
||||
).map(([id, lab]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setLens(id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold border transition-colors ${
|
||||
lens === id
|
||||
? 'bg-brand-500 text-white border-brand-500'
|
||||
: 'bg-white text-gray-700 border-gray-200 hover:border-brand-300'
|
||||
}`}
|
||||
>
|
||||
{lab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 -mt-4">{LENS_HELP[lens]}</p>
|
||||
{lens === 'status' && !hasStatusTiming ? (
|
||||
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||||
Aún no hay <code className="font-mono">status_changed_at</code> en
|
||||
warehouse — aplicando filtro por alta. Ejecuta{' '}
|
||||
<code className="font-mono">sql/leads_status_timing.sql</code> en
|
||||
Supabase y deja que los webhooks actualicen contactos.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!crmLeads.length ? (
|
||||
<p className="text-sm text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-4 py-3">
|
||||
Sin leads Clientify en este contexto. Importa el histórico o activa el
|
||||
@@ -97,7 +152,6 @@ export default function CrmDashboard({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* North star */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
{[
|
||||
{
|
||||
@@ -118,12 +172,12 @@ export default function CrmDashboard({
|
||||
{
|
||||
label: 'En oportunidad',
|
||||
value: formatNum(funnel.enOportunidad),
|
||||
icon: <Briefcase className="w-4 h-4 text-sky-400" />,
|
||||
icon: <Briefcase className="w-4 h-4 text-violet-400" />,
|
||||
},
|
||||
{
|
||||
label: 'Lead frío*',
|
||||
label: 'Lead frío',
|
||||
value: formatNum(funnel.leadFrio),
|
||||
icon: <Flame className="w-4 h-4 text-amber-400" />,
|
||||
icon: <Flame className="w-4 h-4 text-sky-400" />,
|
||||
},
|
||||
{
|
||||
label: 'Lead perdido',
|
||||
@@ -133,65 +187,96 @@ export default function CrmDashboard({
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3"
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between text-gray-500 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-wider">{s.label}</p>
|
||||
<div className="flex items-center justify-between text-gray-600 mb-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider">
|
||||
{s.label}
|
||||
</span>
|
||||
{s.icon}
|
||||
</div>
|
||||
<p className="text-xl font-bold text-gray-900">{s.value}</p>
|
||||
<p className="text-xl font-bold text-gray-900 tabular-nums">
|
||||
{s.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-600 -mt-5">
|
||||
*Frío incluye templado / caliente / cold-lead.
|
||||
</p>
|
||||
|
||||
{/* % cliente por canal — paid first */}
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
% a cliente por canal
|
||||
</h3>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Estado Clientify = <span className="text-gray-400">cliente</span>{' '}
|
||||
(ganado). Paid arriba; resto de atribución abajo.
|
||||
(ganado). Ads spend del periodo es referencia.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{channelRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-6 text-center">
|
||||
Sin canales en el periodo.
|
||||
{!channelRows.length ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
Sin leads en esta lente/periodo.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto space-y-6">
|
||||
<ChannelTable
|
||||
title="Paid (Meta / Google)"
|
||||
rows={paidChannels}
|
||||
spendByChannel={spendByChannel}
|
||||
/>
|
||||
<ChannelTable
|
||||
title="Resto de canales CRM"
|
||||
rows={otherChannels}
|
||||
spendByChannel={spendByChannel}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-gray-600 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 pr-3 font-medium">Clientes</th>
|
||||
<th className="py-2 pr-3 font-medium">% a cliente</th>
|
||||
<th className="py-2 font-medium">Inversión ads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...paidChannels, ...otherChannels].map((row) => (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 font-semibold capitalize text-gray-800">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
|
||||
{formatNum(row.leads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-800">
|
||||
{formatNum(row.cliente)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
|
||||
>
|
||||
{row.clienteRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-2.5 tabular-nums text-gray-700">
|
||||
{spendByChannel.get(row.channel)
|
||||
? formatEur(spendByChannel.get(row.channel)!)
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-gray-500">
|
||||
La inversión por canal (si hay ads) es solo referencia; el % a cliente
|
||||
es CRM. Lente activa: {lens}.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Funnel statuses */}
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Funnel por estado</h3>
|
||||
{statusRows.length === 0 ? (
|
||||
<p className="text-xs text-gray-500 py-4 text-center">Sin datos.</p>
|
||||
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-3 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-gray-900">Desglose de status</h3>
|
||||
{!statusRows.length ? (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">Sin datos.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-200">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-gray-600 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Estado</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 font-medium">% del total</th>
|
||||
<th className="py-2 pr-3 font-medium">Status</th>
|
||||
<th className="py-2 pr-3 font-medium">N</th>
|
||||
<th className="py-2 font-medium">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -200,13 +285,11 @@ export default function CrmDashboard({
|
||||
key={row.status}
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-gray-800 capitalize">
|
||||
{row.status}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
<td className="py-2.5 pr-3 text-gray-800">{row.status}</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{formatNum(row.count)}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-800">
|
||||
<td className="py-2.5 tabular-nums text-gray-600">
|
||||
{row.pct.toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
@@ -216,91 +299,6 @@ export default function CrmDashboard({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<p className="text-[11px] text-gray-600">
|
||||
La inversión por canal (si hay ads) es solo referencia; el % a cliente es
|
||||
100% CRM (Clientify).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelTable({
|
||||
title,
|
||||
rows,
|
||||
spendByChannel,
|
||||
}: {
|
||||
title: string;
|
||||
rows: CrmChannelStats[];
|
||||
spendByChannel: Map<string, number>;
|
||||
}) {
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">Sin filas.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
|
||||
{title}
|
||||
</p>
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-gray-500 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">Canal</th>
|
||||
<th className="py-2 pr-3 font-medium">Leads</th>
|
||||
<th className="py-2 pr-3 font-medium">Clientes</th>
|
||||
<th className="py-2 pr-3 font-medium">% a cliente</th>
|
||||
<th className="py-2 pr-3 font-medium">En oportunidad</th>
|
||||
<th className="py-2 pr-3 font-medium">Frío*</th>
|
||||
<th className="py-2 pr-3 font-medium">Perdido</th>
|
||||
<th className="py-2 font-medium">Spend ads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const spend = spendByChannel.get(row.channel) || 0;
|
||||
return (
|
||||
<tr
|
||||
key={row.channel}
|
||||
className="border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="py-2.5 pr-3 text-brand-700 font-semibold capitalize">
|
||||
{row.channel}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
{formatNum(row.leads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-emerald-300 font-medium">
|
||||
{formatNum(row.cliente)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 font-semibold ${rateClass(row.clienteRate)}`}
|
||||
>
|
||||
{row.clienteRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
{formatNum(row.enOportunidad)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
{formatNum(row.leadFrio)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800">
|
||||
{formatNum(row.leadPerdido)}
|
||||
</td>
|
||||
<td className="py-2.5 text-gray-400">
|
||||
{spend ? formatEur(spend) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
isoYesterday,
|
||||
} from '@/lib/ad-tracker-range';
|
||||
|
||||
const STORAGE_KEY = '4v_global_date_range_v1';
|
||||
const STORAGE_KEY = '4v_global_date_range_v2';
|
||||
|
||||
type Stored = {
|
||||
preset: RangePreset;
|
||||
@@ -38,24 +38,24 @@ const DateRangeContext = createContext<Ctx | null>(null);
|
||||
function loadStored(): Stored {
|
||||
const y = isoYesterday();
|
||||
if (typeof window === 'undefined') {
|
||||
return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
if (!raw) return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
const j = JSON.parse(raw) as Stored;
|
||||
return {
|
||||
preset: j.preset || 'last_7d',
|
||||
preset: j.preset || 'last_30d',
|
||||
customSince: j.customSince || y,
|
||||
customUntil: j.customUntil || y,
|
||||
};
|
||||
} catch {
|
||||
return { preset: 'last_7d', customSince: y, customUntil: y };
|
||||
return { preset: 'last_30d', customSince: y, customUntil: y };
|
||||
}
|
||||
}
|
||||
|
||||
export function DateRangeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [preset, setPresetState] = useState<RangePreset>('last_7d');
|
||||
const [preset, setPresetState] = useState<RangePreset>('last_30d');
|
||||
const [customSince, setCustomSince] = useState('');
|
||||
const [customUntil, setCustomUntil] = useState('');
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
formatNum,
|
||||
formatPct,
|
||||
attributionCross,
|
||||
buildRangeDeltas,
|
||||
filterDailyByDayRange,
|
||||
filterDealsForRange,
|
||||
filterLeadsByDayRange,
|
||||
@@ -50,22 +51,31 @@ function DeltaTriple({
|
||||
delta,
|
||||
invert,
|
||||
empty,
|
||||
format,
|
||||
current,
|
||||
}: {
|
||||
delta: Delta;
|
||||
invert?: boolean;
|
||||
empty?: boolean;
|
||||
format: (v: number) => string;
|
||||
current: number;
|
||||
}) {
|
||||
if (empty) {
|
||||
return (
|
||||
<p className="mt-3 text-[11px] text-gray-500 border border-dashed border-gray-200 rounded-md px-2 py-2 text-center">
|
||||
Sin cierres en el periodo — sincroniza deals Clientify
|
||||
Sin cierres en este rango — amplía el periodo o revisa deals ganados en
|
||||
Clientify
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const cells: { label: string; v: number | null }[] = [
|
||||
{ label: 'WoW', v: delta.wow },
|
||||
{ label: 'MoM', v: delta.mom },
|
||||
{ label: 'YoY', v: delta.yoy },
|
||||
const cells: {
|
||||
label: string;
|
||||
v: number | null;
|
||||
base: number | null;
|
||||
}[] = [
|
||||
{ label: 'WoW', v: delta.wow, base: delta.wowBase },
|
||||
{ label: 'MoM', v: delta.mom, base: delta.momBase },
|
||||
{ label: 'YoY', v: delta.yoy, base: delta.yoyBase },
|
||||
];
|
||||
return (
|
||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||
@@ -85,6 +95,9 @@ function DeltaTriple({
|
||||
: good
|
||||
? ArrowUpRight
|
||||
: ArrowDownRight;
|
||||
const hasBase = c.base !== null && !(c.base === 0 && c.v === null);
|
||||
const absDelta =
|
||||
hasBase && c.base !== null ? current - c.base : null;
|
||||
return (
|
||||
<div
|
||||
key={c.label}
|
||||
@@ -99,6 +112,27 @@ function DeltaTriple({
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{formatPct(c.v)}
|
||||
</p>
|
||||
{hasBase ? (
|
||||
<>
|
||||
<p className="text-[11px] font-semibold text-gray-800 mt-1 tabular-nums leading-tight">
|
||||
prev {format(c.base ?? 0)}
|
||||
</p>
|
||||
{absDelta !== null && (
|
||||
<p
|
||||
className={`text-[10px] font-medium tabular-nums leading-tight ${color}`}
|
||||
>
|
||||
Δ{' '}
|
||||
{absDelta > 0
|
||||
? `+${format(absDelta)}`
|
||||
: format(absDelta)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[10px] text-gray-500 mt-1 leading-tight">
|
||||
sin histórico
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -127,6 +161,7 @@ export default function MetricsDashboard({
|
||||
dailyRows = [],
|
||||
crmLeads = [],
|
||||
wonDeals = [],
|
||||
cohortSource = 'deals',
|
||||
}: {
|
||||
label: string;
|
||||
portfolioWide: boolean;
|
||||
@@ -145,6 +180,7 @@ export default function MetricsDashboard({
|
||||
dailyRows?: AdDailyRow[];
|
||||
crmLeads?: CrmLeadRow[];
|
||||
wonDeals?: DealWonRow[];
|
||||
cohortSource?: 'deals' | 'cliente_leads' | 'none';
|
||||
}) {
|
||||
const { since, until, label: rangeHint } = useDateRange();
|
||||
|
||||
@@ -178,9 +214,15 @@ export default function MetricsDashboard({
|
||||
return withCohortFromDeals(base, rangedDeals);
|
||||
}, [rangedDaily, rangedDeals, weeklyRows, since, until, month]);
|
||||
|
||||
/** Comparatives relative to the selected range (not “today”). */
|
||||
const rangeDeltas = useMemo(
|
||||
() => buildRangeDeltas(dailyRows, wonDeals, since, until),
|
||||
[dailyRows, wonDeals, since, until]
|
||||
);
|
||||
|
||||
const attributionRows: AttributionChannelRow[] = useMemo(() => {
|
||||
if (rangedDaily.length || rangedLeads.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads);
|
||||
if (rangedDaily.length || rangedLeads.length || rangedDeals.length) {
|
||||
return attributionCross(rangedDaily, rangedLeads, rangedDeals);
|
||||
}
|
||||
const map = new Map<string, { spend: number; leads: number }>();
|
||||
for (const p of channelPoints) {
|
||||
@@ -200,14 +242,42 @@ export default function MetricsDashboard({
|
||||
gap: -v.leads,
|
||||
cplPlatform: v.leads > 0 ? v.spend / v.leads : 0,
|
||||
cplCrm: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
roas: 0,
|
||||
cac: 0,
|
||||
}))
|
||||
.sort((a, b) => b.spend - a.spend);
|
||||
}, [rangedDaily, rangedLeads, channelPoints, since, until]);
|
||||
}, [rangedDaily, rangedLeads, rangedDeals, channelPoints, since, until]);
|
||||
|
||||
const attributionTotals = useMemo(() => {
|
||||
return attributionRows.reduce(
|
||||
(a, r) => ({
|
||||
spend: a.spend + r.spend,
|
||||
platformLeads: a.platformLeads + r.platformLeads,
|
||||
crmLeads: a.crmLeads + r.crmLeads,
|
||||
gap: a.gap + r.gap,
|
||||
cierres: a.cierres + r.cierres,
|
||||
ventas: a.ventas + r.ventas,
|
||||
}),
|
||||
{
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
gap: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
}
|
||||
);
|
||||
}, [attributionRows]);
|
||||
|
||||
const crmPeriodTotal = rangedLeads.length;
|
||||
const hasCrm = crmLeads.length > 0;
|
||||
const hasCohort = rangedDeals.length > 0;
|
||||
const cierres = periodTotals.deals;
|
||||
const cohortFromLeads = cohortSource === 'cliente_leads';
|
||||
const revenueMissing =
|
||||
hasCohort && periodTotals.revenue <= 0 && cohortFromLeads;
|
||||
|
||||
const cards: Card[] = [
|
||||
{
|
||||
@@ -236,8 +306,10 @@ export default function MetricsDashboard({
|
||||
key: 'revenue',
|
||||
label: 'Ventas (CRM)',
|
||||
hint: hasCohort
|
||||
? `${formatNum(cierres)} deals ganados · importe Clientify`
|
||||
: 'Deals won Clientify en el periodo',
|
||||
? cohortFromLeads
|
||||
? `${formatNum(cierres)} clientes Clientify · importe no en webhook`
|
||||
: `${formatNum(cierres)} deals ganados · importe Clientify`
|
||||
: 'Deals won / status cliente Clientify en el periodo',
|
||||
format: formatEur,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-600" />,
|
||||
cohort: true,
|
||||
@@ -246,7 +318,7 @@ export default function MetricsDashboard({
|
||||
key: 'cac',
|
||||
label: 'CAC',
|
||||
hint: hasCohort
|
||||
? `Inversión ads ÷ ${formatNum(cierres)} ventas`
|
||||
? `Inversión ads ÷ ${formatNum(cierres)} ${cohortFromLeads ? 'clientes' : 'ventas'}`
|
||||
: 'Inversión ÷ deals ganados (no leads)',
|
||||
format: (v) => `${formatNum(v)} €`,
|
||||
icon: <Target className="w-4 h-4 text-amber-600" />,
|
||||
@@ -256,7 +328,9 @@ export default function MetricsDashboard({
|
||||
{
|
||||
key: 'roas',
|
||||
label: 'ROAS ventas',
|
||||
hint: 'Ventas CRM ÷ inversión ads',
|
||||
hint: revenueMissing
|
||||
? 'Falta importe en CRM (API BPS) para ROAS'
|
||||
: `Importe deals ganados Clientify (${formatEur(periodTotals.revenue)}) ÷ inversión ads (${formatEur(periodTotals.spend)})`,
|
||||
format: (v) => `${formatNum(v)}x`,
|
||||
icon: <TrendingUp className="w-4 h-4 text-emerald-700" />,
|
||||
cohort: true,
|
||||
@@ -287,7 +361,11 @@ export default function MetricsDashboard({
|
||||
const worstGap = [...attributionRows].sort(
|
||||
(a, b) => Math.abs(b.gap) - Math.abs(a.gap)
|
||||
)[0];
|
||||
return { top, worstGap };
|
||||
const platTotal = attributionRows.reduce((a, r) => a + r.platformLeads, 0);
|
||||
const crmTotal = attributionRows.reduce((a, r) => a + r.crmLeads, 0);
|
||||
const crmPaid = paid.reduce((a, r) => a + r.crmLeads, 0);
|
||||
const crmOther = crmTotal - crmPaid;
|
||||
return { top, worstGap, platTotal, crmTotal, crmPaid, crmOther };
|
||||
}, [attributionRows]);
|
||||
|
||||
return (
|
||||
@@ -307,8 +385,20 @@ export default function MetricsDashboard({
|
||||
|
||||
{!hasCohort && (
|
||||
<p className="text-xs text-gray-700 border border-gray-300 bg-gray-50 rounded-lg px-3 py-2">
|
||||
Ventas / CAC / ROAS vacíos: no hay deals ganados (Clientify) con
|
||||
fecha de cierre en este periodo. El gasto de ads no cuenta como venta.
|
||||
Ventas / CAC / ROAS vacíos en <strong>{rangeHint}</strong>: no hay
|
||||
deals ganados ni contactos con status «cliente» en este rango
|
||||
{wonDeals.length
|
||||
? ` (sí hay ${formatNum(wonDeals.length)} en el histórico — prueba «30 días» o «Mes»).`
|
||||
: '.'}{' '}
|
||||
El gasto de ads no cuenta como venta.
|
||||
</p>
|
||||
)}
|
||||
{cohortFromLeads && hasCohort && (
|
||||
<p className="text-xs text-amber-900 border border-amber-300 bg-amber-50 rounded-lg px-3 py-2">
|
||||
Cohorte desde contactos Clientify status «cliente» (no hay deals
|
||||
ganados sincronizados). Importe € incompleto hasta conectar la API
|
||||
Clientify de este tenant. Meta pixel reporta compras en el periodo;
|
||||
el CRM marca clientes sin amount en el webhook.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -350,7 +440,13 @@ export default function MetricsDashboard({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{cards.map((c) => {
|
||||
const emptyCohort = Boolean(c.cohort) && !hasCohort;
|
||||
const value = emptyCohort ? '—' : c.format(periodTotals[c.key]);
|
||||
const showClientesCount =
|
||||
c.key === 'revenue' && revenueMissing && !emptyCohort;
|
||||
const value = emptyCohort
|
||||
? '—'
|
||||
: showClientesCount
|
||||
? `${formatNum(cierres)} clientes`
|
||||
: c.format(periodTotals[c.key]);
|
||||
return (
|
||||
<div
|
||||
key={c.key}
|
||||
@@ -367,9 +463,11 @@ export default function MetricsDashboard({
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 mt-1">{c.hint}</p>
|
||||
<DeltaTriple
|
||||
delta={deltas[c.key]}
|
||||
delta={rangeDeltas[c.key] ?? deltas[c.key]}
|
||||
invert={c.invert}
|
||||
empty={emptyCohort}
|
||||
format={c.format}
|
||||
current={periodTotals[c.key]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -383,16 +481,9 @@ export default function MetricsDashboard({
|
||||
Cruce por canal · ads vs CRM
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mt-0.5">
|
||||
Gasto y leads de plataforma vs leads CRM (como en{' '}
|
||||
<a
|
||||
href="https://mide-analytics-reports.vercel.app/como-combinamos-datos.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-brand-600 underline"
|
||||
>
|
||||
combinación de datos
|
||||
</a>
|
||||
): el coste vive en ads; el resultado de negocio en CRM/GA.
|
||||
Gasto y conversiones de ads vs altas / cierres / ventas CRM. Las
|
||||
ventas de deals sin fuente de contacto aparecen en{' '}
|
||||
<span className="font-medium">sin-atribuir</span>.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 shrink-0 font-medium">
|
||||
@@ -401,16 +492,17 @@ export default function MetricsDashboard({
|
||||
</div>
|
||||
{channelInsight && (
|
||||
<p className="text-xs text-gray-800 bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
|
||||
<strong>Resumen:</strong> más inversión en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.top.channel}
|
||||
</span>{' '}
|
||||
({formatEur(channelInsight.top.spend)}). Mayor desfase ads↔CRM en{' '}
|
||||
<strong>Resumen:</strong> plat {formatNum(channelInsight.platTotal)}{' '}
|
||||
vs CRM {formatNum(channelInsight.crmTotal)} (gap{' '}
|
||||
{channelInsight.crmTotal - channelInsight.platTotal > 0 ? '+' : ''}
|
||||
{formatNum(channelInsight.crmTotal - channelInsight.platTotal)}). De
|
||||
los CRM, {formatNum(channelInsight.crmPaid)} en canales con gasto
|
||||
ads y {formatNum(channelInsight.crmOther)} en otras fuentes
|
||||
(integrations, ecommerce, messenger…). Mayor desfase en{' '}
|
||||
<span className="capitalize font-semibold">
|
||||
{channelInsight.worstGap.channel}
|
||||
</span>{' '}
|
||||
(gap {channelInsight.worstGap.gap > 0 ? '+' : ''}
|
||||
{formatNum(channelInsight.worstGap.gap)}).
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
{attributionRows.length === 0 ? (
|
||||
@@ -427,6 +519,9 @@ export default function MetricsDashboard({
|
||||
<th className="py-2 pr-3 font-semibold">Leads plat</th>
|
||||
<th className="py-2 pr-3 font-semibold">Leads CRM</th>
|
||||
<th className="py-2 pr-3 font-semibold">Gap</th>
|
||||
<th className="py-2 pr-3 font-semibold">Cierres</th>
|
||||
<th className="py-2 pr-3 font-semibold">Ventas</th>
|
||||
<th className="py-2 pr-3 font-semibold">ROAS</th>
|
||||
<th className="py-2 pr-3 font-semibold">CPL plat</th>
|
||||
<th className="py-2 font-semibold">CPL CRM</th>
|
||||
</tr>
|
||||
@@ -469,6 +564,15 @@ export default function MetricsDashboard({
|
||||
? `${row.gap > 0 ? '+' : ''}${formatNum(row.gap)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{hasCrm ? formatNum(row.cierres) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-900 font-semibold tabular-nums">
|
||||
{row.ventas > 0 ? formatEur(row.ventas) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{row.roas > 0 ? `${formatNum(row.roas)}x` : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 text-gray-800 tabular-nums">
|
||||
{row.cplPlatform
|
||||
? `${formatNum(row.cplPlatform)} €`
|
||||
@@ -482,6 +586,70 @@ export default function MetricsDashboard({
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{attributionRows.length > 0 && (
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td className="py-2.5 pr-3 text-gray-900">Total</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||||
{attributionTotals.spend
|
||||
? formatEur(attributionTotals.spend)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{formatNum(attributionTotals.platformLeads)}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{hasCrm ? formatNum(attributionTotals.crmLeads) : '—'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2.5 pr-3 tabular-nums ${
|
||||
hasCrm
|
||||
? gapClass(attributionTotals.gap)
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{hasCrm
|
||||
? `${attributionTotals.gap > 0 ? '+' : ''}${formatNum(attributionTotals.gap)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{hasCrm ? formatNum(attributionTotals.cierres) : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums text-gray-900">
|
||||
{attributionTotals.ventas > 0 || periodTotals.revenue > 0
|
||||
? formatEur(
|
||||
attributionTotals.ventas || periodTotals.revenue
|
||||
)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{attributionTotals.spend > 0 &&
|
||||
(attributionTotals.ventas || periodTotals.revenue) > 0
|
||||
? `${formatNum(
|
||||
(attributionTotals.ventas ||
|
||||
periodTotals.revenue) / attributionTotals.spend
|
||||
)}x`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 pr-3 tabular-nums">
|
||||
{attributionTotals.platformLeads > 0
|
||||
? `${formatNum(
|
||||
attributionTotals.spend /
|
||||
attributionTotals.platformLeads
|
||||
)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="py-2.5 tabular-nums">
|
||||
{hasCrm && attributionTotals.crmLeads > 0
|
||||
? `${formatNum(
|
||||
attributionTotals.spend /
|
||||
attributionTotals.crmLeads
|
||||
)} €`
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
@@ -498,13 +666,13 @@ export default function MetricsDashboard({
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`s-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
className="flex-1 min-w-0 h-full flex items-end"
|
||||
title={`${d.date}: ${formatEur(d.spend)}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-brand-400/80 hover:bg-brand-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.spend / maxSpend) * 100)}%`,
|
||||
height: `${Math.max(4, (d.spend / maxSpend) * 80)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -523,13 +691,18 @@ export default function MetricsDashboard({
|
||||
{dailyChart.map((d) => (
|
||||
<div
|
||||
key={`l-${d.date}`}
|
||||
className="flex-1 min-w-0"
|
||||
className="flex-1 min-w-0 h-full flex items-end"
|
||||
title={`${d.date}: ${formatNum(d.leads)} leads`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t bg-sky-400/80 hover:bg-sky-500"
|
||||
style={{
|
||||
height: `${Math.max(4, (d.leads / Math.max(1, ...dailyChart.map((x) => x.leads))) * 100)}%`,
|
||||
height: `${Math.max(
|
||||
4,
|
||||
(d.leads /
|
||||
Math.max(1, ...dailyChart.map((x) => x.leads))) *
|
||||
80
|
||||
)}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/lib/match';
|
||||
|
||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v18';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
export const META_OAUTH_PROVIDER = 'meta_ads';
|
||||
|
||||
export function metaEnvConfigured(): boolean {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||
|
||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v21';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
|
||||
export type SyncTenantResult = {
|
||||
tenantId: string;
|
||||
@@ -20,8 +20,25 @@ type IntegrationRow = {
|
||||
provider: string;
|
||||
external_account_id: string | null;
|
||||
status?: string;
|
||||
meta?: {
|
||||
extra_ad_accounts?: { account_id?: string; external_account_id?: string }[];
|
||||
alternate_accounts?: { external_account_id?: string }[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
function extraAccountIds(row: IntegrationRow | undefined): string[] {
|
||||
if (!row) return [];
|
||||
const out: string[] = [];
|
||||
for (const x of row.meta?.extra_ad_accounts || []) {
|
||||
const id = x.account_id || x.external_account_id;
|
||||
if (id) out.push(id);
|
||||
}
|
||||
for (const x of row.meta?.alternate_accounts || []) {
|
||||
if (x.external_account_id) out.push(x.external_account_id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type TenantRow = { id: string; name: string };
|
||||
|
||||
type AdDailyInsert = {
|
||||
@@ -114,22 +131,39 @@ function gadsHeaders(accessToken: string, loginCustomerId?: string) {
|
||||
return h;
|
||||
}
|
||||
|
||||
async function fetchMetaAdDaily(
|
||||
accountId: string,
|
||||
tenantId: string,
|
||||
days: number
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const token = await resolveMetaAccessToken();
|
||||
if (!token) throw new Error('Sin token Meta');
|
||||
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||
const since = new Date();
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const until = new Date();
|
||||
const timeRange = JSON.stringify({
|
||||
since: since.toISOString().slice(0, 10),
|
||||
until: until.toISOString().slice(0, 10),
|
||||
});
|
||||
function isoDayUTC(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Inclusive day windows of at most `chunkDays` from `since` → today. */
|
||||
function dayWindows(days: number, chunkDays: number): { since: string; until: string }[] {
|
||||
const until = new Date();
|
||||
const start = new Date();
|
||||
start.setUTCDate(start.getUTCDate() - days);
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
until.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const out: { since: string; until: string }[] = [];
|
||||
let cursor = new Date(start);
|
||||
while (cursor <= until) {
|
||||
const end = new Date(cursor);
|
||||
end.setUTCDate(end.getUTCDate() + chunkDays - 1);
|
||||
if (end > until) end.setTime(until.getTime());
|
||||
out.push({ since: isoDayUTC(cursor), until: isoDayUTC(end) });
|
||||
cursor = new Date(end);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchMetaInsightsWindow(
|
||||
act: string,
|
||||
token: string,
|
||||
tenantId: string,
|
||||
since: string,
|
||||
until: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const timeRange = JSON.stringify({ since, until });
|
||||
const out: AdDailyInsert[] = [];
|
||||
let url: string | null =
|
||||
`${META_GRAPH}/${act}/insights` +
|
||||
@@ -177,19 +211,32 @@ async function fetchMetaAdDaily(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleCampaignDaily(
|
||||
customerId: string,
|
||||
async function fetchMetaAdDaily(
|
||||
accountId: string,
|
||||
tenantId: string,
|
||||
days: number,
|
||||
days: number
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const token = await resolveMetaAccessToken();
|
||||
if (!token) throw new Error('Sin token Meta');
|
||||
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||
// Meta rejects huge ad-level ranges — pull in ~30d windows
|
||||
const windows = dayWindows(days, 30);
|
||||
const out: AdDailyInsert[] = [];
|
||||
for (const w of windows) {
|
||||
out.push(
|
||||
...(await fetchMetaInsightsWindow(act, token, tenantId, w.since, w.until))
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleWindow(
|
||||
cid: string,
|
||||
tenantId: string,
|
||||
sinceStr: string,
|
||||
untilStr: string,
|
||||
accessToken: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const cid = customerId.replace(/-/g, '');
|
||||
const since = new Date();
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const until = new Date();
|
||||
const sinceStr = since.toISOString().slice(0, 10);
|
||||
const untilStr = until.toISOString().slice(0, 10);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
campaign.id,
|
||||
@@ -304,6 +351,23 @@ async function fetchGoogleCampaignDaily(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleCampaignDaily(
|
||||
customerId: string,
|
||||
tenantId: string,
|
||||
days: number,
|
||||
accessToken: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const cid = customerId.replace(/-/g, '');
|
||||
const windows = dayWindows(days, 90);
|
||||
const out: AdDailyInsert[] = [];
|
||||
for (const w of windows) {
|
||||
out.push(
|
||||
...(await fetchGoogleWindow(cid, tenantId, w.since, w.until, accessToken))
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function replaceDaily(
|
||||
tenantId: string,
|
||||
platforms: string[],
|
||||
@@ -350,17 +414,23 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const sinceStr = since.toISOString().slice(0, 10);
|
||||
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const daily = (await r.json()) as {
|
||||
const daily: {
|
||||
date?: string;
|
||||
platform?: string;
|
||||
spend?: number;
|
||||
platform_leads?: number;
|
||||
}[];
|
||||
}[] = [];
|
||||
const pageSize = 1000;
|
||||
for (let offset = 0; offset < 200000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads&order=date.asc&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const chunk = (await r.json()) as typeof daily;
|
||||
daily.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
|
||||
type Agg = { spend: number; leads: number };
|
||||
const map = new Map<string, Agg>();
|
||||
@@ -401,27 +471,33 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
|
||||
|
||||
if (payload.length) {
|
||||
// kpi_weekly may be a materialized view — best-effort only
|
||||
const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
const msg = await ins.text();
|
||||
if (msg.includes('materialized view')) {
|
||||
// UI falls back to ad_daily; skip hard failure
|
||||
return 0;
|
||||
for (let i = 0; i < payload.length; i += 400) {
|
||||
const chunk = payload.slice(i, i + 400);
|
||||
const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
const msg = await ins.text();
|
||||
if (msg.includes('materialized view')) {
|
||||
// UI falls back to ad_daily; skip hard failure
|
||||
return 0;
|
||||
}
|
||||
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
|
||||
}
|
||||
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
|
||||
}
|
||||
}
|
||||
return payload.length;
|
||||
}
|
||||
|
||||
/** Max lookback for intentional backfills (≈ Jan 2024 → now). Cron stays short. */
|
||||
export const ADS_BACKFILL_MAX_DAYS = 1000;
|
||||
|
||||
export async function syncAdsForTenants(opts?: {
|
||||
tenantId?: string;
|
||||
days?: number;
|
||||
@@ -429,20 +505,29 @@ export async function syncAdsForTenants(opts?: {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const base = supabaseUrl();
|
||||
const days = opts?.days ?? 30;
|
||||
const days = Math.min(
|
||||
ADS_BACKFILL_MAX_DAYS,
|
||||
Math.max(1, opts?.days ?? 30)
|
||||
);
|
||||
|
||||
const tRes = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name&order=name.asc`,
|
||||
`${base}/rest/v1/tenants?select=id,name&agency_followed=neq.false&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) throw new Error(await tRes.text());
|
||||
let tenants = (await tRes.json()) as TenantRow[];
|
||||
if (opts?.tenantId) {
|
||||
tenants = tenants.filter((t) => t.id === opts.tenantId);
|
||||
// Explicit id may be archived satellite — still allow
|
||||
const all = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const allTenants = all.ok ? ((await all.json()) as TenantRow[]) : tenants;
|
||||
tenants = allTenants.filter((t) => t.id === opts.tenantId);
|
||||
}
|
||||
|
||||
const iRes = await fetch(
|
||||
`${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status&external_account_id=not.is.null`,
|
||||
`${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status,meta&external_account_id=not.is.null`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!iRes.ok) throw new Error(await iRes.text());
|
||||
@@ -471,52 +556,77 @@ export async function syncAdsForTenants(opts?: {
|
||||
const platforms: string[] = [];
|
||||
|
||||
const meta = mine.find((i) => i.provider === 'meta_ads' && i.external_account_id);
|
||||
if (meta?.external_account_id) {
|
||||
const metaIds = [
|
||||
...(meta?.external_account_id ? [meta.external_account_id] : []),
|
||||
...extraAccountIds(meta),
|
||||
];
|
||||
for (const accountId of [...new Set(metaIds)]) {
|
||||
try {
|
||||
const mrows = await fetchMetaAdDaily(
|
||||
meta.external_account_id,
|
||||
tenant.id,
|
||||
days
|
||||
);
|
||||
const mrows = await fetchMetaAdDaily(accountId, tenant.id, days);
|
||||
rows.push(...mrows);
|
||||
platforms.push('meta');
|
||||
result.metaRows = mrows.length;
|
||||
result.metaRows += mrows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'meta sync error'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result.metaRows > 0) platforms.push('meta');
|
||||
|
||||
const gads = mine.find(
|
||||
(i) => i.provider === 'google_ads' && i.external_account_id
|
||||
);
|
||||
if (gads?.external_account_id) {
|
||||
const googleIds = [
|
||||
...(gads?.external_account_id ? [gads.external_account_id] : []),
|
||||
...extraAccountIds(gads),
|
||||
];
|
||||
if (googleIds.length) {
|
||||
if (!gToken) {
|
||||
result.errors.push('Google Ads sin token OAuth');
|
||||
} else {
|
||||
try {
|
||||
const grows = await fetchGoogleCampaignDaily(
|
||||
gads.external_account_id,
|
||||
tenant.id,
|
||||
days,
|
||||
gToken
|
||||
);
|
||||
rows.push(...grows);
|
||||
platforms.push('google');
|
||||
result.googleRows = grows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'google sync error'
|
||||
);
|
||||
for (const customerId of [...new Set(googleIds)]) {
|
||||
try {
|
||||
const grows = await fetchGoogleCampaignDaily(
|
||||
customerId,
|
||||
tenant.id,
|
||||
days,
|
||||
gToken
|
||||
);
|
||||
rows.push(...grows);
|
||||
result.googleRows += grows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'google sync error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.googleRows > 0) platforms.push('google');
|
||||
|
||||
if (rows.length && platforms.length) {
|
||||
try {
|
||||
await replaceDaily(tenant.id, platforms, rows, days);
|
||||
result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days);
|
||||
const now = new Date().toISOString();
|
||||
for (const p of platforms) {
|
||||
const provider = p === 'meta' ? 'meta_ads' : 'google_ads';
|
||||
await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${provider}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'warehouse write error'
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
/** Agency portfolio helpers — providers, role gates, service-role REST. */
|
||||
|
||||
export const INTEGRATION_PROVIDERS = [
|
||||
{ id: 'meta_ads', label: 'Meta Ads', group: 'paid' },
|
||||
{ id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid' },
|
||||
{ id: 'ga4', label: 'Google Analytics 4', group: 'analytics' },
|
||||
{ id: 'search_console', label: 'Search Console', group: 'analytics' },
|
||||
{ id: 'tag_manager', label: 'Tag Manager', group: 'analytics' },
|
||||
{ id: 'clientify', label: 'Clientify', group: 'crm' },
|
||||
{ id: 'klaviyo', label: 'Klaviyo', group: 'email' },
|
||||
{ id: 'mailchimp', label: 'Mailchimp', group: 'email' },
|
||||
{ id: 'google_drive', label: 'Google Drive', group: 'files' },
|
||||
{ id: 'wordpress', label: 'WordPress', group: 'cms' },
|
||||
{ id: 'prestashop', label: 'PrestaShop', group: 'cms' },
|
||||
{ id: 'shopify', label: 'Shopify', group: 'cms' },
|
||||
{ id: 'instagram', label: 'Instagram', group: 'social' },
|
||||
{ id: 'facebook', label: 'Facebook Page', group: 'social' },
|
||||
{ id: 'tiktok', label: 'TikTok', group: 'social' },
|
||||
{ id: 'linkedin', label: 'LinkedIn', group: 'social' },
|
||||
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops' },
|
||||
{ id: 'meta_ads', label: 'Meta Ads', group: 'paid', live: true },
|
||||
{ id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid', live: true },
|
||||
{ id: 'ga4', label: 'Google Analytics 4', group: 'analytics', live: true },
|
||||
{ id: 'search_console', label: 'Search Console', group: 'analytics', live: true },
|
||||
{ id: 'tag_manager', label: 'Tag Manager', group: 'analytics', live: false },
|
||||
{ id: 'clientify', label: 'Clientify', group: 'crm', live: true },
|
||||
{ id: 'klaviyo', label: 'Klaviyo', group: 'email', live: false },
|
||||
{ id: 'mailchimp', label: 'Mailchimp', group: 'email', live: false },
|
||||
{ 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: 'instagram', label: 'Instagram', group: 'social', live: true },
|
||||
{ id: 'facebook', label: 'Facebook Page', group: 'social', live: true },
|
||||
{ id: 'tiktok', label: 'TikTok', group: 'social', live: false },
|
||||
{ id: 'linkedin', label: 'LinkedIn', group: 'social', live: false },
|
||||
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops', live: true },
|
||||
] as const;
|
||||
|
||||
export type IntegrationProviderId =
|
||||
|
||||
26
src/lib/cron-auth.ts
Normal file
26
src/lib/cron-auth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Shared auth for /api/cron/* — Bearer $CRON_SECRET or ?secret= */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export function assertCronAuth(
|
||||
req: NextRequest
|
||||
): NextResponse | null {
|
||||
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) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'CRON_SECRET no configurado',
|
||||
hint: 'Define CRON_SECRET en Dokploy y programa el cron diario.',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -215,8 +215,9 @@ export async function applyOrganicMatches(
|
||||
meta: {
|
||||
property_id: m.ga4.propertyId,
|
||||
measurement_id: m.ga4.measurementId,
|
||||
matched_at: new Date().toISOString(),
|
||||
},
|
||||
last_sync_at: new Date().toISOString(),
|
||||
// last_sync_at solo tras volcar organic_daily (no al emparejar)
|
||||
error_message: null,
|
||||
});
|
||||
if (ok) ga4 += 1;
|
||||
@@ -231,8 +232,10 @@ export async function applyOrganicMatches(
|
||||
status: 'connected',
|
||||
external_account_id: m.gsc.siteUrl,
|
||||
label: m.gsc.siteUrl,
|
||||
meta: { site_url: m.gsc.siteUrl },
|
||||
last_sync_at: new Date().toISOString(),
|
||||
meta: {
|
||||
site_url: m.gsc.siteUrl,
|
||||
matched_at: new Date().toISOString(),
|
||||
},
|
||||
error_message: null,
|
||||
}
|
||||
);
|
||||
@@ -686,19 +689,21 @@ export async function syncAllConnectedOrganic(opts?: {
|
||||
gscSiteUrl: cfg.gscSiteUrl,
|
||||
days,
|
||||
});
|
||||
// bump last_sync on integrations
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ last_sync_at: new Date().toISOString() }),
|
||||
}
|
||||
);
|
||||
// Solo marcar last_sync_at si hubo filas (no mentir tras match vacío)
|
||||
if (r.daysWritten > 0) {
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ last_sync_at: new Date().toISOString() }),
|
||||
}
|
||||
);
|
||||
}
|
||||
results.push({ tenantId, ...r });
|
||||
} catch (e) {
|
||||
results.push({
|
||||
|
||||
@@ -22,9 +22,29 @@ export function scoreNameToTenant(
|
||||
if (f === n || f === s) return 100;
|
||||
if (n && (f.includes(n) || n.includes(f))) return 85;
|
||||
if (s && (f.includes(s) || s.includes(f))) return 80;
|
||||
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
||||
// Ignore generic tokens that cause false clinic/school matches
|
||||
const WEAK = new Set([
|
||||
'clinica',
|
||||
'clinic',
|
||||
'dental',
|
||||
'school',
|
||||
'app',
|
||||
'the',
|
||||
'and',
|
||||
'de',
|
||||
'la',
|
||||
'el',
|
||||
'los',
|
||||
'las',
|
||||
'del',
|
||||
]);
|
||||
const ft = new Set(
|
||||
f.split(' ').filter((t) => t.length > 2 && !WEAK.has(t))
|
||||
);
|
||||
const nt = new Set(
|
||||
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
||||
[...n.split(' '), ...s.split(' ')].filter(
|
||||
(t) => t.length > 2 && !WEAK.has(t)
|
||||
)
|
||||
);
|
||||
if (!ft.size || !nt.size) return 0;
|
||||
let hit = 0;
|
||||
|
||||
@@ -41,6 +41,10 @@ export type Delta = {
|
||||
wow: number | null;
|
||||
mom: number | null;
|
||||
yoy: number | null;
|
||||
/** Absolute value of the comparison baseline (previous period). */
|
||||
wowBase: number | null;
|
||||
momBase: number | null;
|
||||
yoyBase: number | null;
|
||||
};
|
||||
|
||||
function n(v: unknown): number {
|
||||
@@ -163,6 +167,9 @@ export function buildComparisons(rows: KpiWeeklyRow[]): {
|
||||
wow: pctChange(currentWeek[k], prevWeek[k]),
|
||||
mom: pctChange(currentMonth[k], prevMonth[k]),
|
||||
yoy: pctChange(currentMonth[k], currentMonthLastYear[k]),
|
||||
wowBase: prevWeek[k],
|
||||
momBase: prevMonth[k],
|
||||
yoyBase: currentMonthLastYear[k],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,6 +183,107 @@ export function buildComparisons(rows: KpiWeeklyRow[]): {
|
||||
};
|
||||
}
|
||||
|
||||
function shiftIsoDays(iso: string, deltaDays: number): string {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + deltaDays);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
function inclusiveDaySpan(since: string, until: string): number {
|
||||
const a = new Date(`${since}T00:00:00Z`).getTime();
|
||||
const b = new Date(`${until}T00:00:00Z`).getTime();
|
||||
return Math.max(1, Math.round((b - a) / 86400000) + 1);
|
||||
}
|
||||
|
||||
function shiftYear(iso: string, years: number): string {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
d.setUTCFullYear(d.getUTCFullYear() + years);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
/** Totals for an inclusive day window from daily ads + won deals. */
|
||||
export function totalsForDayRange(
|
||||
daily: AdDailyRow[],
|
||||
deals: DealWonRow[],
|
||||
since: string,
|
||||
until: string
|
||||
): MetricTotals {
|
||||
const base = metricTotalsFromDaily(filterDailyByDayRange(daily, since, until));
|
||||
return withCohortFromDeals(base, filterDealsForRange(deals, since, until));
|
||||
}
|
||||
|
||||
/**
|
||||
* WoW / MoM / YoY relative to the selected date range (not “today”).
|
||||
* - WoW: previous window of the same length
|
||||
* - MoM: previous calendar month if the range is a full month; else prior equal-length window
|
||||
* - YoY: same calendar dates one year earlier
|
||||
*/
|
||||
export function buildRangeDeltas(
|
||||
daily: AdDailyRow[],
|
||||
deals: DealWonRow[],
|
||||
since: string,
|
||||
until: string
|
||||
): Record<keyof MetricTotals, Delta> {
|
||||
const span = inclusiveDaySpan(since, until);
|
||||
const cur = totalsForDayRange(daily, deals, since, until);
|
||||
|
||||
const wowUntil = shiftIsoDays(since, -1);
|
||||
const wowSince = shiftIsoDays(wowUntil, -(span - 1));
|
||||
const wow = totalsForDayRange(daily, deals, wowSince, wowUntil);
|
||||
|
||||
const sinceD = new Date(`${since}T00:00:00Z`);
|
||||
const untilD = new Date(`${until}T00:00:00Z`);
|
||||
const isFullMonth =
|
||||
sinceD.getUTCDate() === 1 &&
|
||||
untilD.getUTCDate() ===
|
||||
new Date(
|
||||
Date.UTC(untilD.getUTCFullYear(), untilD.getUTCMonth() + 1, 0)
|
||||
).getUTCDate() &&
|
||||
sinceD.getUTCMonth() === untilD.getUTCMonth() &&
|
||||
sinceD.getUTCFullYear() === untilD.getUTCFullYear();
|
||||
|
||||
let momSince: string;
|
||||
let momUntil: string;
|
||||
if (isFullMonth) {
|
||||
const prevMonthEnd = shiftIsoDays(since, -1);
|
||||
const prevMonthStart = new Date(
|
||||
Date.UTC(sinceD.getUTCFullYear(), sinceD.getUTCMonth() - 1, 1)
|
||||
);
|
||||
momSince = isoDate(prevMonthStart);
|
||||
momUntil = prevMonthEnd;
|
||||
} else {
|
||||
momUntil = shiftIsoDays(since, -1);
|
||||
momSince = shiftIsoDays(momUntil, -(span - 1));
|
||||
}
|
||||
const mom = totalsForDayRange(daily, deals, momSince, momUntil);
|
||||
|
||||
const yoySince = shiftYear(since, -1);
|
||||
const yoyUntil = shiftYear(until, -1);
|
||||
const yoy = totalsForDayRange(daily, deals, yoySince, yoyUntil);
|
||||
|
||||
const keys: (keyof MetricTotals)[] = [
|
||||
'spend',
|
||||
'leads',
|
||||
'deals',
|
||||
'revenue',
|
||||
'cac',
|
||||
'roas',
|
||||
'cpl',
|
||||
];
|
||||
const deltas = {} as Record<keyof MetricTotals, Delta>;
|
||||
for (const k of keys) {
|
||||
deltas[k] = {
|
||||
wow: pctChange(cur[k], wow[k]),
|
||||
mom: pctChange(cur[k], mom[k]),
|
||||
yoy: pctChange(cur[k], yoy[k]),
|
||||
wowBase: wow[k],
|
||||
momBase: mom[k],
|
||||
yoyBase: yoy[k],
|
||||
};
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
export function formatEur(v: number): string {
|
||||
return `${v.toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 0,
|
||||
@@ -409,6 +517,8 @@ export type DealWonRow = {
|
||||
won_at?: string | null;
|
||||
stage?: string | null;
|
||||
tenant_id?: string | null;
|
||||
/** Optional source/channel when joined from lead or Clientify contact. */
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
export function filterDealsForRange(
|
||||
@@ -484,6 +594,9 @@ export function mergeDealsIntoWeekly(
|
||||
export type CrmLeadRow = {
|
||||
source?: string | null;
|
||||
created_at?: string | null;
|
||||
/** Last status change (→ cliente). Prefer for “ventas en periodo”. */
|
||||
status_changed_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
status?: string | null;
|
||||
tenant_id?: string | null;
|
||||
};
|
||||
@@ -523,6 +636,9 @@ export function normalizeLeadStatus(status?: string | null): string {
|
||||
const s = (status || '').toLowerCase().trim();
|
||||
if (!s) return 'sin-estado';
|
||||
if (s === 'cold-lead') return 'lead frío';
|
||||
if (s === 'client' || s === 'customer' || s === 'won' || s === 'ganado') {
|
||||
return 'cliente';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -530,6 +646,46 @@ export function isClienteStatus(status?: string | null): boolean {
|
||||
return normalizeLeadStatus(status) === 'cliente';
|
||||
}
|
||||
|
||||
/**
|
||||
* When Clientify won-deals are missing (webhook without deals / no API key),
|
||||
* treat contacts marked as cliente as cohort closes for Ventas / CAC / ROAS.
|
||||
* Amount stays 0 unless the lead carries one — revenue may be incomplete.
|
||||
*/
|
||||
export function clienteLeadsAsWonDeals(leads: CrmLeadRow[]): DealWonRow[] {
|
||||
const out: DealWonRow[] = [];
|
||||
for (const l of leads) {
|
||||
if (!isClienteStatus(l.status)) continue;
|
||||
const wonAt = (
|
||||
l.status_changed_at ||
|
||||
l.updated_at ||
|
||||
l.created_at ||
|
||||
''
|
||||
).trim();
|
||||
if (!wonAt) continue;
|
||||
out.push({
|
||||
stage: 'won',
|
||||
amount: 0,
|
||||
won_at: wonAt,
|
||||
tenant_id: l.tenant_id || null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer explicit won deals; if none, fall back to Clientify «cliente» leads.
|
||||
*/
|
||||
export function resolveWonCohort(
|
||||
deals: DealWonRow[],
|
||||
leads: CrmLeadRow[]
|
||||
): { deals: DealWonRow[]; source: 'deals' | 'cliente_leads' | 'none' } {
|
||||
const won = deals.filter((d) => !d.stage || d.stage === 'won');
|
||||
if (won.length) return { deals: won, source: 'deals' };
|
||||
const fromLeads = clienteLeadsAsWonDeals(leads);
|
||||
if (fromLeads.length) return { deals: fromLeads, source: 'cliente_leads' };
|
||||
return { deals: [], source: 'none' };
|
||||
}
|
||||
|
||||
export type CrmFunnelTotals = {
|
||||
total: number;
|
||||
cliente: number;
|
||||
@@ -647,13 +803,27 @@ export function buildCrmChannelStats(leads: CrmLeadRow[]): CrmChannelStats[] {
|
||||
.sort((a, b) => b.cliente - a.cliente || b.leads - a.leads);
|
||||
}
|
||||
|
||||
export type CrmDateLens = 'created' | 'status' | 'snapshot';
|
||||
|
||||
/**
|
||||
* Filter CRM leads by date.
|
||||
* - created: alta del lead (default histórico)
|
||||
* - status: cuándo cambió el status (ventas/clientes en el periodo) — needs status_changed_at
|
||||
* - snapshot: ignore dates (estado actual del CRM)
|
||||
*/
|
||||
export function filterLeadsByDayRange(
|
||||
rows: CrmLeadRow[],
|
||||
since: string,
|
||||
until: string
|
||||
until: string,
|
||||
lens: CrmDateLens = 'created'
|
||||
): CrmLeadRow[] {
|
||||
if (lens === 'snapshot') return rows;
|
||||
return rows.filter((r) => {
|
||||
const d = (r.created_at || '').slice(0, 10);
|
||||
const raw =
|
||||
lens === 'status'
|
||||
? r.status_changed_at || r.updated_at || r.created_at
|
||||
: r.created_at;
|
||||
const d = (raw || '').slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
});
|
||||
}
|
||||
@@ -666,42 +836,72 @@ export type AttributionChannelRow = {
|
||||
gap: number;
|
||||
cplPlatform: number;
|
||||
cplCrm: number;
|
||||
/** Contacts marked cliente (by CRM source) in the period. */
|
||||
cierres: number;
|
||||
/** Won-deal revenue attributed to channel (or sin-atribuir). */
|
||||
ventas: number;
|
||||
/** Ventas ÷ inversión (0 if no spend). */
|
||||
roas: number;
|
||||
/** Inversión ÷ cierres (0 if no cierres). */
|
||||
cac: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify).
|
||||
* Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI).
|
||||
* Ventas come from won deals; without deal.source they land in `sin-atribuir`.
|
||||
*/
|
||||
export function attributionCross(
|
||||
daily: AdDailyRow[],
|
||||
leads: CrmLeadRow[]
|
||||
leads: CrmLeadRow[],
|
||||
deals: DealWonRow[] = []
|
||||
): AttributionChannelRow[] {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ spend: number; platformLeads: number; crmLeads: number }
|
||||
{
|
||||
spend: number;
|
||||
platformLeads: number;
|
||||
crmLeads: number;
|
||||
cierres: number;
|
||||
ventas: number;
|
||||
}
|
||||
>();
|
||||
|
||||
const ensure = (channel: string) => {
|
||||
let cur = map.get(channel);
|
||||
if (!cur) {
|
||||
cur = {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
};
|
||||
map.set(channel, cur);
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
for (const r of daily) {
|
||||
const channel = (r.platform || 'otros').toLowerCase();
|
||||
const cur = map.get(channel) || {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
};
|
||||
const cur = ensure(channel);
|
||||
cur.spend += n(r.spend);
|
||||
cur.platformLeads += n(r.platform_leads ?? r.conversions);
|
||||
map.set(channel, cur);
|
||||
}
|
||||
|
||||
for (const r of leads) {
|
||||
const channel = normalizeLeadChannel(r.source);
|
||||
const cur = map.get(channel) || {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
};
|
||||
const cur = ensure(channel);
|
||||
cur.crmLeads += 1;
|
||||
map.set(channel, cur);
|
||||
if (isClienteStatus(r.status)) cur.cierres += 1;
|
||||
}
|
||||
|
||||
for (const d of deals) {
|
||||
if (d.stage && d.stage !== 'won') continue;
|
||||
const raw = (d.source || '').trim();
|
||||
const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir';
|
||||
const cur = ensure(channel);
|
||||
cur.ventas += n(d.amount);
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
@@ -713,10 +913,15 @@ export function attributionCross(
|
||||
gap: v.crmLeads - v.platformLeads,
|
||||
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
|
||||
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
|
||||
cierres: v.cierres,
|
||||
ventas: v.ventas,
|
||||
roas: v.spend > 0 ? v.ventas / v.spend : 0,
|
||||
cac: v.cierres > 0 ? v.spend / v.cierres : 0,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.spend - a.spend ||
|
||||
b.ventas - a.ventas ||
|
||||
b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -500,23 +500,48 @@ export function matchDocsToTenants(
|
||||
tenants: { id: string; name: string; slug: string }[],
|
||||
docs: ShDoc[]
|
||||
): DocMatchResult[] {
|
||||
// Expand short brand names so BPS ↔ BLUE PARROT SCHOOL matches
|
||||
const expanded = tenants.flatMap((t) => {
|
||||
const names = tenantMatchNames(t);
|
||||
return names.map((name) => ({ ...t, name }));
|
||||
});
|
||||
const accounts = docs.map((d) => ({
|
||||
id: d.id,
|
||||
name: cleanClientDocName(d.name),
|
||||
}));
|
||||
const matched = matchAccountsToTenants(tenants, accounts, {
|
||||
const matched = matchAccountsToTenants(expanded, accounts, {
|
||||
threshold: 50,
|
||||
ambiguousGap: 8,
|
||||
});
|
||||
return matched.map((m) => ({
|
||||
docId: m.accountId || '',
|
||||
docName:
|
||||
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
|
||||
tenantId: m.status === 'matched' ? m.tenantId : null,
|
||||
tenantName: m.status === 'matched' ? m.tenantName : null,
|
||||
score: m.score,
|
||||
status: m.status,
|
||||
}));
|
||||
// Deduplicate by original tenant id (aliases create duplicate rows)
|
||||
const byTenant = new Map<string, (typeof matched)[number]>();
|
||||
for (const m of matched) {
|
||||
if (m.status !== 'matched') continue;
|
||||
const prev = byTenant.get(m.tenantId);
|
||||
if (!prev || m.score > prev.score) byTenant.set(m.tenantId, m);
|
||||
}
|
||||
return tenants.map((t) => {
|
||||
const m = byTenant.get(t.id);
|
||||
if (m) {
|
||||
return {
|
||||
docId: m.accountId || '',
|
||||
docName:
|
||||
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
score: m.score,
|
||||
status: 'matched' as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
docId: '',
|
||||
docName: '',
|
||||
tenantId: null,
|
||||
tenantName: null,
|
||||
score: 0,
|
||||
status: 'unmatched' as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Best single-doc match for one tenant (manual zoom). */
|
||||
@@ -524,19 +549,57 @@ export function bestDocForTenant(
|
||||
tenant: { name: string; slug: string },
|
||||
docs: ShDoc[]
|
||||
): { doc: ShDoc; score: number } | null {
|
||||
const names = tenantMatchNames(tenant);
|
||||
let best: { doc: ShDoc; score: number } | null = null;
|
||||
for (const d of docs) {
|
||||
const score = scoreNameToTenant(
|
||||
cleanClientDocName(d.name),
|
||||
tenant.name,
|
||||
tenant.slug
|
||||
);
|
||||
const cleaned = cleanClientDocName(d.name);
|
||||
let score = 0;
|
||||
for (const n of names) {
|
||||
score = Math.max(
|
||||
score,
|
||||
scoreNameToTenant(cleaned, n, tenant.slug)
|
||||
);
|
||||
}
|
||||
if (!best || score > best.score) best = { doc: d, score };
|
||||
}
|
||||
if (!best || best.score < 50) return null;
|
||||
if (!best || best.score < 75) return null;
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Extra names so short brands match full Superhuman / Drive titles. */
|
||||
const TENANT_DOC_ALIASES: Record<string, string[]> = {
|
||||
bps: ['Blue Parrot School', 'Blue Parrot', 'BPS'],
|
||||
'agua-miia': ['Agua Miia', 'Aguamiia', 'Agua'],
|
||||
'brava-dermoestetica': ['Brava', 'Brava Dermoestética'],
|
||||
andala: ['Andala'],
|
||||
'loybee-app': ['LoyBee', 'Loybee', 'LoyBee App'],
|
||||
noberplast: ['Noberplast'],
|
||||
'virginia-molet': ['Virginia Molet', 'Virginia'],
|
||||
'4ventures-hub': ['4Ventures', '4VENTURES'],
|
||||
'delgado-abogados': ['Delgado Abogados', 'Delgado'],
|
||||
'equinsa-parking': ['Equinsa', 'Equinsa Parking'],
|
||||
'consigue-tus-papeles': ['Consigue Tus Papeles', 'CTP'],
|
||||
'talento-y-deporte': ['Talento y Deporte', 'Talento'],
|
||||
'la-freseria': ['La Fresería', 'Fresería', 'Freseria'],
|
||||
gravitae: ['Gravitae'],
|
||||
menttum: ['Menttum'],
|
||||
'wine-advisors': ['Wine Advisors', 'Wine'],
|
||||
ecp: ['ECP'],
|
||||
mcd: ['MCD'],
|
||||
'fbm-abogados': ['FBM Abogados', 'FBM', 'Herencias'],
|
||||
'clinica-sonrisas': ['Clínica Dental Sonrisas', 'Sonrisas', 'Clinica Sonrisas'],
|
||||
'sumar-inversion': ['Sumar Inversión', 'Sumar'],
|
||||
};
|
||||
|
||||
function tenantMatchNames(tenant: { name: string; slug: string }): string[] {
|
||||
const out = [tenant.name];
|
||||
const aliases = TENANT_DOC_ALIASES[tenant.slug.toLowerCase()] || [];
|
||||
for (const a of aliases) {
|
||||
if (!out.some((x) => normalizeName(x) === normalizeName(a))) out.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type DriveDocHit = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -603,7 +666,25 @@ export async function findSeguimientoGoogleDoc(
|
||||
return scoreDriveHits(files2, cleaned);
|
||||
}
|
||||
const files = ((await r.json()) as { files?: DriveDocHit[] }).files || [];
|
||||
return scoreDriveHits(files, cleaned);
|
||||
const scored = scoreDriveHits(files, cleaned);
|
||||
if (scored) return scored;
|
||||
|
||||
// Also search «Seguimiento …» without «Semanal» (e.g. «Seguimiento BPS»)
|
||||
const p2 = new URLSearchParams({
|
||||
q: "name contains 'Seguimiento' and mimeType='application/vnd.google-apps.document' and trashed=false",
|
||||
fields: 'files(id,name,webViewLink,modifiedTime)',
|
||||
pageSize: '50',
|
||||
corpora: 'allDrives',
|
||||
includeItemsFromAllDrives: 'true',
|
||||
supportsAllDrives: 'true',
|
||||
});
|
||||
const r2 = await fetch(`https://www.googleapis.com/drive/v3/files?${p2}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r2.ok) return null;
|
||||
const files2 = ((await r2.json()) as { files?: DriveDocHit[] }).files || [];
|
||||
return scoreDriveHits(files2, cleaned);
|
||||
}
|
||||
|
||||
function scoreDriveHits(
|
||||
@@ -613,7 +694,11 @@ function scoreDriveHits(
|
||||
let best: { file: DriveDocHit; score: number } | null = null;
|
||||
for (const f of files) {
|
||||
const fn = normalizeName(f.name || '');
|
||||
let score = /seguimiento\s+semanal/.test(fn) ? 20 : 0;
|
||||
let score = /seguimiento\s+semanal/.test(fn)
|
||||
? 20
|
||||
: /\bseguimiento\b/.test(fn)
|
||||
? 10
|
||||
: 0;
|
||||
for (const h of cleaned) {
|
||||
const hn = normalizeName(h);
|
||||
if (!hn) continue;
|
||||
@@ -624,10 +709,10 @@ function scoreDriveHits(
|
||||
.split(' ')
|
||||
.filter(
|
||||
(t) =>
|
||||
t.length > 3 && t !== 'seguimiento' && t !== 'semanal'
|
||||
t.length > 2 && t !== 'seguimiento' && t !== 'semanal'
|
||||
);
|
||||
const hits = tokens.filter((t) => fn.includes(t)).length;
|
||||
if (tokens.length && hits / tokens.length >= 0.6) {
|
||||
if (tokens.length && hits / tokens.length >= 0.5) {
|
||||
score = Math.max(score, 70 + hits * 5);
|
||||
}
|
||||
}
|
||||
@@ -742,20 +827,30 @@ export async function buildSeguimientoRowsForDoc(opts: {
|
||||
const now = new Date().toISOString();
|
||||
const rows: SeguimientoSyncRow[] = [];
|
||||
|
||||
// 1) Google Doc «Seguimiento Semanal …» — HTML completo partido por fecha
|
||||
// 1) Google Doc «Seguimiento Semanal …» / «Seguimiento {cliente}» — HTML por día
|
||||
const slugGuess = /\bbps\b|parrot/i.test(opts.tenantName || '')
|
||||
? 'bps'
|
||||
: (opts.tenantName || '').toLowerCase().replace(/\s+/g, '-');
|
||||
const aliasHints = opts.tenantName
|
||||
? tenantMatchNames({ name: opts.tenantName, slug: slugGuess })
|
||||
: ['BPS', 'Blue Parrot School'];
|
||||
const nameHints = [
|
||||
...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name),
|
||||
opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '',
|
||||
opts.tenantName ? `Seguimiento ${opts.tenantName}` : '',
|
||||
...aliasHints.flatMap((n) => [
|
||||
`Seguimiento Semanal ${n}`,
|
||||
`Seguimiento ${n}`,
|
||||
]),
|
||||
'Seguimiento BPS',
|
||||
].filter(Boolean);
|
||||
|
||||
if (exportBodies && nameHints.length) {
|
||||
try {
|
||||
const hit = await findSeguimientoGoogleDoc(nameHints);
|
||||
if (hit) {
|
||||
const html = await exportGoogleDocHtml(hit.id);
|
||||
rows.push(
|
||||
...dayRowsFromHtml({
|
||||
let dayRows: SeguimientoSyncRow[] = [];
|
||||
try {
|
||||
const html = await exportGoogleDocHtml(hit.id);
|
||||
dayRows = dayRowsFromHtml({
|
||||
tenantId: opts.tenantId,
|
||||
html,
|
||||
fileId: hit.id,
|
||||
@@ -764,8 +859,37 @@ export async function buildSeguimientoRowsForDoc(opts: {
|
||||
`https://docs.google.com/document/d/${hit.id}/edit`,
|
||||
maxEntries,
|
||||
now,
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
// Docs grandes: HTML export 403 «too large» → plain text
|
||||
const plain = await exportGoogleDocPlain(hit.id);
|
||||
const sections = splitNotesByDay(plain).slice(0, maxEntries);
|
||||
dayRows = sections.map((sec) => ({
|
||||
tenant_id: opts.tenantId,
|
||||
source: 'superhuman' as const,
|
||||
entry_type: 'meeting' as const,
|
||||
external_id: `gdoc:${hit.id}:${sec.dateIso}`,
|
||||
title: `Seguimiento · ${sec.label}`,
|
||||
summary:
|
||||
sec.body
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
|
||||
.find((l) => l.length > 8)
|
||||
?.slice(0, 220) || null,
|
||||
body_md: `<pre>${sec.body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</pre>`,
|
||||
browser_link:
|
||||
hit.webViewLink ||
|
||||
`https://docs.google.com/document/d/${hit.id}/edit`,
|
||||
metrics: parseMetricsFromMarkdown(sec.body),
|
||||
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
|
||||
synced_at: now,
|
||||
updated_at: now,
|
||||
}));
|
||||
}
|
||||
rows.push(...dayRows);
|
||||
}
|
||||
} catch {
|
||||
// fall through to canvas pages
|
||||
@@ -905,3 +1029,141 @@ export async function patchSuperhumanIntegration(
|
||||
export function docDisplayHint(name: string): string {
|
||||
return normalizeName(cleanClientDocName(name));
|
||||
}
|
||||
|
||||
export type SuperhumanSyncResult = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
docId: string | null;
|
||||
docName: string | null;
|
||||
upserted: number;
|
||||
status: 'synced' | 'unmatched' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Sync seguimiento for all (or one) tenants with Superhuman/Drive notes. */
|
||||
export async function syncAllSuperhumanTenants(opts?: {
|
||||
tenantId?: string | null;
|
||||
maxEntries?: number;
|
||||
exportBodies?: boolean;
|
||||
}): Promise<{ synced: number; results: SuperhumanSyncResult[] }> {
|
||||
if (!superhumanConfigured()) {
|
||||
throw new Error('Falta SUPERHUMAN_DOCS_API_KEY');
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
|
||||
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) throw new Error(await tRes.text());
|
||||
let tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[];
|
||||
if (opts?.tenantId) {
|
||||
tenants = tenants.filter((t) => t.id === opts.tenantId);
|
||||
}
|
||||
|
||||
const maxEntries = Math.min(opts?.maxEntries ?? 100, 150);
|
||||
const exportBodies = opts?.exportBodies !== false;
|
||||
const results: SuperhumanSyncResult[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
let docId: string | null = null;
|
||||
let docName: string | null = null;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
synced: results.filter((r) => r.status === 'synced').length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -52,11 +52,12 @@ export async function middleware(req: NextRequest) {
|
||||
const url = req.nextUrl.clone();
|
||||
const path = url.pathname;
|
||||
|
||||
// Public: login, attribution script, health, inbound webhooks (CRM)
|
||||
// Public: login, attribution script, health, inbound webhooks (CRM), cron (Bearer CRON_SECRET)
|
||||
const isPublic =
|
||||
path === '/login' ||
|
||||
path === '/api/health' ||
|
||||
path.startsWith('/api/webhooks/') ||
|
||||
path.startsWith('/api/cron/') ||
|
||||
path === '/_4v_attr.js' ||
|
||||
path.startsWith('/_next') ||
|
||||
path === '/favicon.ico' ||
|
||||
|
||||
Reference in New Issue
Block a user