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

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