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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -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>

View File

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

View File

@@ -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'

View File

@@ -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
View 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;
}

View File

@@ -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({

View File

@@ -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;

View File

@@ -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)
);
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')}</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,
};
}

View File

@@ -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' ||