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:
@@ -8,7 +8,7 @@ import {
|
||||
} from '@/lib/match';
|
||||
|
||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v18';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
export const META_OAUTH_PROVIDER = 'meta_ads';
|
||||
|
||||
export function metaEnvConfigured(): boolean {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
|
||||
|
||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v21';
|
||||
const GADS_API = 'https://googleads.googleapis.com/v22';
|
||||
|
||||
export type SyncTenantResult = {
|
||||
tenantId: string;
|
||||
@@ -20,8 +20,25 @@ type IntegrationRow = {
|
||||
provider: string;
|
||||
external_account_id: string | null;
|
||||
status?: string;
|
||||
meta?: {
|
||||
extra_ad_accounts?: { account_id?: string; external_account_id?: string }[];
|
||||
alternate_accounts?: { external_account_id?: string }[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
function extraAccountIds(row: IntegrationRow | undefined): string[] {
|
||||
if (!row) return [];
|
||||
const out: string[] = [];
|
||||
for (const x of row.meta?.extra_ad_accounts || []) {
|
||||
const id = x.account_id || x.external_account_id;
|
||||
if (id) out.push(id);
|
||||
}
|
||||
for (const x of row.meta?.alternate_accounts || []) {
|
||||
if (x.external_account_id) out.push(x.external_account_id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type TenantRow = { id: string; name: string };
|
||||
|
||||
type AdDailyInsert = {
|
||||
@@ -114,22 +131,39 @@ function gadsHeaders(accessToken: string, loginCustomerId?: string) {
|
||||
return h;
|
||||
}
|
||||
|
||||
async function fetchMetaAdDaily(
|
||||
accountId: string,
|
||||
tenantId: string,
|
||||
days: number
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const token = await resolveMetaAccessToken();
|
||||
if (!token) throw new Error('Sin token Meta');
|
||||
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||
const since = new Date();
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const until = new Date();
|
||||
const timeRange = JSON.stringify({
|
||||
since: since.toISOString().slice(0, 10),
|
||||
until: until.toISOString().slice(0, 10),
|
||||
});
|
||||
function isoDayUTC(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Inclusive day windows of at most `chunkDays` from `since` → today. */
|
||||
function dayWindows(days: number, chunkDays: number): { since: string; until: string }[] {
|
||||
const until = new Date();
|
||||
const start = new Date();
|
||||
start.setUTCDate(start.getUTCDate() - days);
|
||||
start.setUTCHours(0, 0, 0, 0);
|
||||
until.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const out: { since: string; until: string }[] = [];
|
||||
let cursor = new Date(start);
|
||||
while (cursor <= until) {
|
||||
const end = new Date(cursor);
|
||||
end.setUTCDate(end.getUTCDate() + chunkDays - 1);
|
||||
if (end > until) end.setTime(until.getTime());
|
||||
out.push({ since: isoDayUTC(cursor), until: isoDayUTC(end) });
|
||||
cursor = new Date(end);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchMetaInsightsWindow(
|
||||
act: string,
|
||||
token: string,
|
||||
tenantId: string,
|
||||
since: string,
|
||||
until: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const timeRange = JSON.stringify({ since, until });
|
||||
const out: AdDailyInsert[] = [];
|
||||
let url: string | null =
|
||||
`${META_GRAPH}/${act}/insights` +
|
||||
@@ -177,19 +211,32 @@ async function fetchMetaAdDaily(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleCampaignDaily(
|
||||
customerId: string,
|
||||
async function fetchMetaAdDaily(
|
||||
accountId: string,
|
||||
tenantId: string,
|
||||
days: number,
|
||||
days: number
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const token = await resolveMetaAccessToken();
|
||||
if (!token) throw new Error('Sin token Meta');
|
||||
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
|
||||
// Meta rejects huge ad-level ranges — pull in ~30d windows
|
||||
const windows = dayWindows(days, 30);
|
||||
const out: AdDailyInsert[] = [];
|
||||
for (const w of windows) {
|
||||
out.push(
|
||||
...(await fetchMetaInsightsWindow(act, token, tenantId, w.since, w.until))
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleWindow(
|
||||
cid: string,
|
||||
tenantId: string,
|
||||
sinceStr: string,
|
||||
untilStr: string,
|
||||
accessToken: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const cid = customerId.replace(/-/g, '');
|
||||
const since = new Date();
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const until = new Date();
|
||||
const sinceStr = since.toISOString().slice(0, 10);
|
||||
const untilStr = until.toISOString().slice(0, 10);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
campaign.id,
|
||||
@@ -304,6 +351,23 @@ async function fetchGoogleCampaignDaily(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchGoogleCampaignDaily(
|
||||
customerId: string,
|
||||
tenantId: string,
|
||||
days: number,
|
||||
accessToken: string
|
||||
): Promise<AdDailyInsert[]> {
|
||||
const cid = customerId.replace(/-/g, '');
|
||||
const windows = dayWindows(days, 90);
|
||||
const out: AdDailyInsert[] = [];
|
||||
for (const w of windows) {
|
||||
out.push(
|
||||
...(await fetchGoogleWindow(cid, tenantId, w.since, w.until, accessToken))
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function replaceDaily(
|
||||
tenantId: string,
|
||||
platforms: string[],
|
||||
@@ -350,17 +414,23 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
|
||||
since.setUTCDate(since.getUTCDate() - days);
|
||||
const sinceStr = since.toISOString().slice(0, 10);
|
||||
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const daily = (await r.json()) as {
|
||||
const daily: {
|
||||
date?: string;
|
||||
platform?: string;
|
||||
spend?: number;
|
||||
platform_leads?: number;
|
||||
}[];
|
||||
}[] = [];
|
||||
const pageSize = 1000;
|
||||
for (let offset = 0; offset < 200000; offset += pageSize) {
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads&order=date.asc&limit=${pageSize}&offset=${offset}`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const chunk = (await r.json()) as typeof daily;
|
||||
daily.push(...chunk);
|
||||
if (chunk.length < pageSize) break;
|
||||
}
|
||||
|
||||
type Agg = { spend: number; leads: number };
|
||||
const map = new Map<string, Agg>();
|
||||
@@ -401,27 +471,33 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
|
||||
|
||||
if (payload.length) {
|
||||
// kpi_weekly may be a materialized view — best-effort only
|
||||
const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
const msg = await ins.text();
|
||||
if (msg.includes('materialized view')) {
|
||||
// UI falls back to ad_daily; skip hard failure
|
||||
return 0;
|
||||
for (let i = 0; i < payload.length; i += 400) {
|
||||
const chunk = payload.slice(i, i + 400);
|
||||
const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'resolution=merge-duplicates,return=minimal',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
const msg = await ins.text();
|
||||
if (msg.includes('materialized view')) {
|
||||
// UI falls back to ad_daily; skip hard failure
|
||||
return 0;
|
||||
}
|
||||
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
|
||||
}
|
||||
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
|
||||
}
|
||||
}
|
||||
return payload.length;
|
||||
}
|
||||
|
||||
/** Max lookback for intentional backfills (≈ Jan 2024 → now). Cron stays short. */
|
||||
export const ADS_BACKFILL_MAX_DAYS = 1000;
|
||||
|
||||
export async function syncAdsForTenants(opts?: {
|
||||
tenantId?: string;
|
||||
days?: number;
|
||||
@@ -429,20 +505,29 @@ export async function syncAdsForTenants(opts?: {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const base = supabaseUrl();
|
||||
const days = opts?.days ?? 30;
|
||||
const days = Math.min(
|
||||
ADS_BACKFILL_MAX_DAYS,
|
||||
Math.max(1, opts?.days ?? 30)
|
||||
);
|
||||
|
||||
const tRes = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name&order=name.asc`,
|
||||
`${base}/rest/v1/tenants?select=id,name&agency_followed=neq.false&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) throw new Error(await tRes.text());
|
||||
let tenants = (await tRes.json()) as TenantRow[];
|
||||
if (opts?.tenantId) {
|
||||
tenants = tenants.filter((t) => t.id === opts.tenantId);
|
||||
// Explicit id may be archived satellite — still allow
|
||||
const all = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,name&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
const allTenants = all.ok ? ((await all.json()) as TenantRow[]) : tenants;
|
||||
tenants = allTenants.filter((t) => t.id === opts.tenantId);
|
||||
}
|
||||
|
||||
const iRes = await fetch(
|
||||
`${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status&external_account_id=not.is.null`,
|
||||
`${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status,meta&external_account_id=not.is.null`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!iRes.ok) throw new Error(await iRes.text());
|
||||
@@ -471,52 +556,77 @@ export async function syncAdsForTenants(opts?: {
|
||||
const platforms: string[] = [];
|
||||
|
||||
const meta = mine.find((i) => i.provider === 'meta_ads' && i.external_account_id);
|
||||
if (meta?.external_account_id) {
|
||||
const metaIds = [
|
||||
...(meta?.external_account_id ? [meta.external_account_id] : []),
|
||||
...extraAccountIds(meta),
|
||||
];
|
||||
for (const accountId of [...new Set(metaIds)]) {
|
||||
try {
|
||||
const mrows = await fetchMetaAdDaily(
|
||||
meta.external_account_id,
|
||||
tenant.id,
|
||||
days
|
||||
);
|
||||
const mrows = await fetchMetaAdDaily(accountId, tenant.id, days);
|
||||
rows.push(...mrows);
|
||||
platforms.push('meta');
|
||||
result.metaRows = mrows.length;
|
||||
result.metaRows += mrows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'meta sync error'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result.metaRows > 0) platforms.push('meta');
|
||||
|
||||
const gads = mine.find(
|
||||
(i) => i.provider === 'google_ads' && i.external_account_id
|
||||
);
|
||||
if (gads?.external_account_id) {
|
||||
const googleIds = [
|
||||
...(gads?.external_account_id ? [gads.external_account_id] : []),
|
||||
...extraAccountIds(gads),
|
||||
];
|
||||
if (googleIds.length) {
|
||||
if (!gToken) {
|
||||
result.errors.push('Google Ads sin token OAuth');
|
||||
} else {
|
||||
try {
|
||||
const grows = await fetchGoogleCampaignDaily(
|
||||
gads.external_account_id,
|
||||
tenant.id,
|
||||
days,
|
||||
gToken
|
||||
);
|
||||
rows.push(...grows);
|
||||
platforms.push('google');
|
||||
result.googleRows = grows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'google sync error'
|
||||
);
|
||||
for (const customerId of [...new Set(googleIds)]) {
|
||||
try {
|
||||
const grows = await fetchGoogleCampaignDaily(
|
||||
customerId,
|
||||
tenant.id,
|
||||
days,
|
||||
gToken
|
||||
);
|
||||
rows.push(...grows);
|
||||
result.googleRows += grows.length;
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'google sync error'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.googleRows > 0) platforms.push('google');
|
||||
|
||||
if (rows.length && platforms.length) {
|
||||
try {
|
||||
await replaceDaily(tenant.id, platforms, rows, days);
|
||||
result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days);
|
||||
const now = new Date().toISOString();
|
||||
for (const p of platforms) {
|
||||
const provider = p === 'meta' ? 'meta_ads' : 'google_ads';
|
||||
await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${provider}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push(
|
||||
e instanceof Error ? e.message : 'warehouse write error'
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
/** Agency portfolio helpers — providers, role gates, service-role REST. */
|
||||
|
||||
export const INTEGRATION_PROVIDERS = [
|
||||
{ id: 'meta_ads', label: 'Meta Ads', group: 'paid' },
|
||||
{ id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid' },
|
||||
{ id: 'ga4', label: 'Google Analytics 4', group: 'analytics' },
|
||||
{ id: 'search_console', label: 'Search Console', group: 'analytics' },
|
||||
{ id: 'tag_manager', label: 'Tag Manager', group: 'analytics' },
|
||||
{ id: 'clientify', label: 'Clientify', group: 'crm' },
|
||||
{ id: 'klaviyo', label: 'Klaviyo', group: 'email' },
|
||||
{ id: 'mailchimp', label: 'Mailchimp', group: 'email' },
|
||||
{ id: 'google_drive', label: 'Google Drive', group: 'files' },
|
||||
{ id: 'wordpress', label: 'WordPress', group: 'cms' },
|
||||
{ id: 'prestashop', label: 'PrestaShop', group: 'cms' },
|
||||
{ id: 'shopify', label: 'Shopify', group: 'cms' },
|
||||
{ id: 'instagram', label: 'Instagram', group: 'social' },
|
||||
{ id: 'facebook', label: 'Facebook Page', group: 'social' },
|
||||
{ id: 'tiktok', label: 'TikTok', group: 'social' },
|
||||
{ id: 'linkedin', label: 'LinkedIn', group: 'social' },
|
||||
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops' },
|
||||
{ id: 'meta_ads', label: 'Meta Ads', group: 'paid', live: true },
|
||||
{ id: 'google_ads', label: 'Google Ads (MCC)', group: 'paid', live: true },
|
||||
{ id: 'ga4', label: 'Google Analytics 4', group: 'analytics', live: true },
|
||||
{ id: 'search_console', label: 'Search Console', group: 'analytics', live: true },
|
||||
{ id: 'tag_manager', label: 'Tag Manager', group: 'analytics', live: false },
|
||||
{ id: 'clientify', label: 'Clientify', group: 'crm', live: true },
|
||||
{ id: 'klaviyo', label: 'Klaviyo', group: 'email', live: false },
|
||||
{ id: 'mailchimp', label: 'Mailchimp', group: 'email', live: false },
|
||||
{ id: 'google_drive', label: 'Google Drive', group: 'files', live: true },
|
||||
{ id: 'wordpress', label: 'WordPress', group: 'cms', live: false },
|
||||
{ id: 'prestashop', label: 'PrestaShop', group: 'cms', live: false },
|
||||
{ id: 'shopify', label: 'Shopify', group: 'cms', live: false },
|
||||
{ id: 'instagram', label: 'Instagram', group: 'social', live: true },
|
||||
{ id: 'facebook', label: 'Facebook Page', group: 'social', live: true },
|
||||
{ id: 'tiktok', label: 'TikTok', group: 'social', live: false },
|
||||
{ id: 'linkedin', label: 'LinkedIn', group: 'social', live: false },
|
||||
{ id: 'superhuman_docs', label: 'Superhuman Docs', group: 'ops', live: true },
|
||||
] as const;
|
||||
|
||||
export type IntegrationProviderId =
|
||||
|
||||
26
src/lib/cron-auth.ts
Normal file
26
src/lib/cron-auth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Shared auth for /api/cron/* — Bearer $CRON_SECRET or ?secret= */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export function assertCronAuth(
|
||||
req: NextRequest
|
||||
): NextResponse | null {
|
||||
const secret = process.env.CRON_SECRET?.trim();
|
||||
const auth = req.headers.get('authorization') || '';
|
||||
const q = req.nextUrl.searchParams.get('secret') || '';
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : q;
|
||||
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'CRON_SECRET no configurado',
|
||||
hint: 'Define CRON_SECRET en Dokploy y programa el cron diario.',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -215,8 +215,9 @@ export async function applyOrganicMatches(
|
||||
meta: {
|
||||
property_id: m.ga4.propertyId,
|
||||
measurement_id: m.ga4.measurementId,
|
||||
matched_at: new Date().toISOString(),
|
||||
},
|
||||
last_sync_at: new Date().toISOString(),
|
||||
// last_sync_at solo tras volcar organic_daily (no al emparejar)
|
||||
error_message: null,
|
||||
});
|
||||
if (ok) ga4 += 1;
|
||||
@@ -231,8 +232,10 @@ export async function applyOrganicMatches(
|
||||
status: 'connected',
|
||||
external_account_id: m.gsc.siteUrl,
|
||||
label: m.gsc.siteUrl,
|
||||
meta: { site_url: m.gsc.siteUrl },
|
||||
last_sync_at: new Date().toISOString(),
|
||||
meta: {
|
||||
site_url: m.gsc.siteUrl,
|
||||
matched_at: new Date().toISOString(),
|
||||
},
|
||||
error_message: null,
|
||||
}
|
||||
);
|
||||
@@ -686,19 +689,21 @@ export async function syncAllConnectedOrganic(opts?: {
|
||||
gscSiteUrl: cfg.gscSiteUrl,
|
||||
days,
|
||||
});
|
||||
// bump last_sync on integrations
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ last_sync_at: new Date().toISOString() }),
|
||||
}
|
||||
);
|
||||
// Solo marcar last_sync_at si hubo filas (no mentir tras match vacío)
|
||||
if (r.daysWritten > 0) {
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({ last_sync_at: new Date().toISOString() }),
|
||||
}
|
||||
);
|
||||
}
|
||||
results.push({ tenantId, ...r });
|
||||
} catch (e) {
|
||||
results.push({
|
||||
|
||||
@@ -22,9 +22,29 @@ export function scoreNameToTenant(
|
||||
if (f === n || f === s) return 100;
|
||||
if (n && (f.includes(n) || n.includes(f))) return 85;
|
||||
if (s && (f.includes(s) || s.includes(f))) return 80;
|
||||
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
||||
// Ignore generic tokens that cause false clinic/school matches
|
||||
const WEAK = new Set([
|
||||
'clinica',
|
||||
'clinic',
|
||||
'dental',
|
||||
'school',
|
||||
'app',
|
||||
'the',
|
||||
'and',
|
||||
'de',
|
||||
'la',
|
||||
'el',
|
||||
'los',
|
||||
'las',
|
||||
'del',
|
||||
]);
|
||||
const ft = new Set(
|
||||
f.split(' ').filter((t) => t.length > 2 && !WEAK.has(t))
|
||||
);
|
||||
const nt = new Set(
|
||||
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
||||
[...n.split(' '), ...s.split(' ')].filter(
|
||||
(t) => t.length > 2 && !WEAK.has(t)
|
||||
)
|
||||
);
|
||||
if (!ft.size || !nt.size) return 0;
|
||||
let hit = 0;
|
||||
|
||||
@@ -41,6 +41,10 @@ export type Delta = {
|
||||
wow: number | null;
|
||||
mom: number | null;
|
||||
yoy: number | null;
|
||||
/** Absolute value of the comparison baseline (previous period). */
|
||||
wowBase: number | null;
|
||||
momBase: number | null;
|
||||
yoyBase: number | null;
|
||||
};
|
||||
|
||||
function n(v: unknown): number {
|
||||
@@ -163,6 +167,9 @@ export function buildComparisons(rows: KpiWeeklyRow[]): {
|
||||
wow: pctChange(currentWeek[k], prevWeek[k]),
|
||||
mom: pctChange(currentMonth[k], prevMonth[k]),
|
||||
yoy: pctChange(currentMonth[k], currentMonthLastYear[k]),
|
||||
wowBase: prevWeek[k],
|
||||
momBase: prevMonth[k],
|
||||
yoyBase: currentMonthLastYear[k],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,6 +183,107 @@ export function buildComparisons(rows: KpiWeeklyRow[]): {
|
||||
};
|
||||
}
|
||||
|
||||
function shiftIsoDays(iso: string, deltaDays: number): string {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + deltaDays);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
function inclusiveDaySpan(since: string, until: string): number {
|
||||
const a = new Date(`${since}T00:00:00Z`).getTime();
|
||||
const b = new Date(`${until}T00:00:00Z`).getTime();
|
||||
return Math.max(1, Math.round((b - a) / 86400000) + 1);
|
||||
}
|
||||
|
||||
function shiftYear(iso: string, years: number): string {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
d.setUTCFullYear(d.getUTCFullYear() + years);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
/** Totals for an inclusive day window from daily ads + won deals. */
|
||||
export function totalsForDayRange(
|
||||
daily: AdDailyRow[],
|
||||
deals: DealWonRow[],
|
||||
since: string,
|
||||
until: string
|
||||
): MetricTotals {
|
||||
const base = metricTotalsFromDaily(filterDailyByDayRange(daily, since, until));
|
||||
return withCohortFromDeals(base, filterDealsForRange(deals, since, until));
|
||||
}
|
||||
|
||||
/**
|
||||
* WoW / MoM / YoY relative to the selected date range (not “today”).
|
||||
* - WoW: previous window of the same length
|
||||
* - MoM: previous calendar month if the range is a full month; else prior equal-length window
|
||||
* - YoY: same calendar dates one year earlier
|
||||
*/
|
||||
export function buildRangeDeltas(
|
||||
daily: AdDailyRow[],
|
||||
deals: DealWonRow[],
|
||||
since: string,
|
||||
until: string
|
||||
): Record<keyof MetricTotals, Delta> {
|
||||
const span = inclusiveDaySpan(since, until);
|
||||
const cur = totalsForDayRange(daily, deals, since, until);
|
||||
|
||||
const wowUntil = shiftIsoDays(since, -1);
|
||||
const wowSince = shiftIsoDays(wowUntil, -(span - 1));
|
||||
const wow = totalsForDayRange(daily, deals, wowSince, wowUntil);
|
||||
|
||||
const sinceD = new Date(`${since}T00:00:00Z`);
|
||||
const untilD = new Date(`${until}T00:00:00Z`);
|
||||
const isFullMonth =
|
||||
sinceD.getUTCDate() === 1 &&
|
||||
untilD.getUTCDate() ===
|
||||
new Date(
|
||||
Date.UTC(untilD.getUTCFullYear(), untilD.getUTCMonth() + 1, 0)
|
||||
).getUTCDate() &&
|
||||
sinceD.getUTCMonth() === untilD.getUTCMonth() &&
|
||||
sinceD.getUTCFullYear() === untilD.getUTCFullYear();
|
||||
|
||||
let momSince: string;
|
||||
let momUntil: string;
|
||||
if (isFullMonth) {
|
||||
const prevMonthEnd = shiftIsoDays(since, -1);
|
||||
const prevMonthStart = new Date(
|
||||
Date.UTC(sinceD.getUTCFullYear(), sinceD.getUTCMonth() - 1, 1)
|
||||
);
|
||||
momSince = isoDate(prevMonthStart);
|
||||
momUntil = prevMonthEnd;
|
||||
} else {
|
||||
momUntil = shiftIsoDays(since, -1);
|
||||
momSince = shiftIsoDays(momUntil, -(span - 1));
|
||||
}
|
||||
const mom = totalsForDayRange(daily, deals, momSince, momUntil);
|
||||
|
||||
const yoySince = shiftYear(since, -1);
|
||||
const yoyUntil = shiftYear(until, -1);
|
||||
const yoy = totalsForDayRange(daily, deals, yoySince, yoyUntil);
|
||||
|
||||
const keys: (keyof MetricTotals)[] = [
|
||||
'spend',
|
||||
'leads',
|
||||
'deals',
|
||||
'revenue',
|
||||
'cac',
|
||||
'roas',
|
||||
'cpl',
|
||||
];
|
||||
const deltas = {} as Record<keyof MetricTotals, Delta>;
|
||||
for (const k of keys) {
|
||||
deltas[k] = {
|
||||
wow: pctChange(cur[k], wow[k]),
|
||||
mom: pctChange(cur[k], mom[k]),
|
||||
yoy: pctChange(cur[k], yoy[k]),
|
||||
wowBase: wow[k],
|
||||
momBase: mom[k],
|
||||
yoyBase: yoy[k],
|
||||
};
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
export function formatEur(v: number): string {
|
||||
return `${v.toLocaleString('es-ES', {
|
||||
minimumFractionDigits: 0,
|
||||
@@ -409,6 +517,8 @@ export type DealWonRow = {
|
||||
won_at?: string | null;
|
||||
stage?: string | null;
|
||||
tenant_id?: string | null;
|
||||
/** Optional source/channel when joined from lead or Clientify contact. */
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
export function filterDealsForRange(
|
||||
@@ -484,6 +594,9 @@ export function mergeDealsIntoWeekly(
|
||||
export type CrmLeadRow = {
|
||||
source?: string | null;
|
||||
created_at?: string | null;
|
||||
/** Last status change (→ cliente). Prefer for “ventas en periodo”. */
|
||||
status_changed_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
status?: string | null;
|
||||
tenant_id?: string | null;
|
||||
};
|
||||
@@ -523,6 +636,9 @@ export function normalizeLeadStatus(status?: string | null): string {
|
||||
const s = (status || '').toLowerCase().trim();
|
||||
if (!s) return 'sin-estado';
|
||||
if (s === 'cold-lead') return 'lead frío';
|
||||
if (s === 'client' || s === 'customer' || s === 'won' || s === 'ganado') {
|
||||
return 'cliente';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -530,6 +646,46 @@ export function isClienteStatus(status?: string | null): boolean {
|
||||
return normalizeLeadStatus(status) === 'cliente';
|
||||
}
|
||||
|
||||
/**
|
||||
* When Clientify won-deals are missing (webhook without deals / no API key),
|
||||
* treat contacts marked as cliente as cohort closes for Ventas / CAC / ROAS.
|
||||
* Amount stays 0 unless the lead carries one — revenue may be incomplete.
|
||||
*/
|
||||
export function clienteLeadsAsWonDeals(leads: CrmLeadRow[]): DealWonRow[] {
|
||||
const out: DealWonRow[] = [];
|
||||
for (const l of leads) {
|
||||
if (!isClienteStatus(l.status)) continue;
|
||||
const wonAt = (
|
||||
l.status_changed_at ||
|
||||
l.updated_at ||
|
||||
l.created_at ||
|
||||
''
|
||||
).trim();
|
||||
if (!wonAt) continue;
|
||||
out.push({
|
||||
stage: 'won',
|
||||
amount: 0,
|
||||
won_at: wonAt,
|
||||
tenant_id: l.tenant_id || null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer explicit won deals; if none, fall back to Clientify «cliente» leads.
|
||||
*/
|
||||
export function resolveWonCohort(
|
||||
deals: DealWonRow[],
|
||||
leads: CrmLeadRow[]
|
||||
): { deals: DealWonRow[]; source: 'deals' | 'cliente_leads' | 'none' } {
|
||||
const won = deals.filter((d) => !d.stage || d.stage === 'won');
|
||||
if (won.length) return { deals: won, source: 'deals' };
|
||||
const fromLeads = clienteLeadsAsWonDeals(leads);
|
||||
if (fromLeads.length) return { deals: fromLeads, source: 'cliente_leads' };
|
||||
return { deals: [], source: 'none' };
|
||||
}
|
||||
|
||||
export type CrmFunnelTotals = {
|
||||
total: number;
|
||||
cliente: number;
|
||||
@@ -647,13 +803,27 @@ export function buildCrmChannelStats(leads: CrmLeadRow[]): CrmChannelStats[] {
|
||||
.sort((a, b) => b.cliente - a.cliente || b.leads - a.leads);
|
||||
}
|
||||
|
||||
export type CrmDateLens = 'created' | 'status' | 'snapshot';
|
||||
|
||||
/**
|
||||
* Filter CRM leads by date.
|
||||
* - created: alta del lead (default histórico)
|
||||
* - status: cuándo cambió el status (ventas/clientes en el periodo) — needs status_changed_at
|
||||
* - snapshot: ignore dates (estado actual del CRM)
|
||||
*/
|
||||
export function filterLeadsByDayRange(
|
||||
rows: CrmLeadRow[],
|
||||
since: string,
|
||||
until: string
|
||||
until: string,
|
||||
lens: CrmDateLens = 'created'
|
||||
): CrmLeadRow[] {
|
||||
if (lens === 'snapshot') return rows;
|
||||
return rows.filter((r) => {
|
||||
const d = (r.created_at || '').slice(0, 10);
|
||||
const raw =
|
||||
lens === 'status'
|
||||
? r.status_changed_at || r.updated_at || r.created_at
|
||||
: r.created_at;
|
||||
const d = (raw || '').slice(0, 10);
|
||||
return d >= since && d <= until;
|
||||
});
|
||||
}
|
||||
@@ -666,42 +836,72 @@ export type AttributionChannelRow = {
|
||||
gap: number;
|
||||
cplPlatform: number;
|
||||
cplCrm: number;
|
||||
/** Contacts marked cliente (by CRM source) in the period. */
|
||||
cierres: number;
|
||||
/** Won-deal revenue attributed to channel (or sin-atribuir). */
|
||||
ventas: number;
|
||||
/** Ventas ÷ inversión (0 if no spend). */
|
||||
roas: number;
|
||||
/** Inversión ÷ cierres (0 if no cierres). */
|
||||
cac: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cross paid platform leads (ad_daily) vs CRM-attributed leads (Clientify).
|
||||
* Gap = CRM − platform (positive ⇒ CRM claims more than the ad UI).
|
||||
* Ventas come from won deals; without deal.source they land in `sin-atribuir`.
|
||||
*/
|
||||
export function attributionCross(
|
||||
daily: AdDailyRow[],
|
||||
leads: CrmLeadRow[]
|
||||
leads: CrmLeadRow[],
|
||||
deals: DealWonRow[] = []
|
||||
): AttributionChannelRow[] {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ spend: number; platformLeads: number; crmLeads: number }
|
||||
{
|
||||
spend: number;
|
||||
platformLeads: number;
|
||||
crmLeads: number;
|
||||
cierres: number;
|
||||
ventas: number;
|
||||
}
|
||||
>();
|
||||
|
||||
const ensure = (channel: string) => {
|
||||
let cur = map.get(channel);
|
||||
if (!cur) {
|
||||
cur = {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
cierres: 0,
|
||||
ventas: 0,
|
||||
};
|
||||
map.set(channel, cur);
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
for (const r of daily) {
|
||||
const channel = (r.platform || 'otros').toLowerCase();
|
||||
const cur = map.get(channel) || {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
};
|
||||
const cur = ensure(channel);
|
||||
cur.spend += n(r.spend);
|
||||
cur.platformLeads += n(r.platform_leads ?? r.conversions);
|
||||
map.set(channel, cur);
|
||||
}
|
||||
|
||||
for (const r of leads) {
|
||||
const channel = normalizeLeadChannel(r.source);
|
||||
const cur = map.get(channel) || {
|
||||
spend: 0,
|
||||
platformLeads: 0,
|
||||
crmLeads: 0,
|
||||
};
|
||||
const cur = ensure(channel);
|
||||
cur.crmLeads += 1;
|
||||
map.set(channel, cur);
|
||||
if (isClienteStatus(r.status)) cur.cierres += 1;
|
||||
}
|
||||
|
||||
for (const d of deals) {
|
||||
if (d.stage && d.stage !== 'won') continue;
|
||||
const raw = (d.source || '').trim();
|
||||
const channel = raw ? normalizeLeadChannel(raw) : 'sin-atribuir';
|
||||
const cur = ensure(channel);
|
||||
cur.ventas += n(d.amount);
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
@@ -713,10 +913,15 @@ export function attributionCross(
|
||||
gap: v.crmLeads - v.platformLeads,
|
||||
cplPlatform: v.platformLeads > 0 ? v.spend / v.platformLeads : 0,
|
||||
cplCrm: v.crmLeads > 0 ? v.spend / v.crmLeads : 0,
|
||||
cierres: v.cierres,
|
||||
ventas: v.ventas,
|
||||
roas: v.spend > 0 ? v.ventas / v.spend : 0,
|
||||
cac: v.cierres > 0 ? v.spend / v.cierres : 0,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.spend - a.spend ||
|
||||
b.ventas - a.ventas ||
|
||||
b.crmLeads + b.platformLeads - (a.crmLeads + a.platformLeads)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -500,23 +500,48 @@ export function matchDocsToTenants(
|
||||
tenants: { id: string; name: string; slug: string }[],
|
||||
docs: ShDoc[]
|
||||
): DocMatchResult[] {
|
||||
// Expand short brand names so BPS ↔ BLUE PARROT SCHOOL matches
|
||||
const expanded = tenants.flatMap((t) => {
|
||||
const names = tenantMatchNames(t);
|
||||
return names.map((name) => ({ ...t, name }));
|
||||
});
|
||||
const accounts = docs.map((d) => ({
|
||||
id: d.id,
|
||||
name: cleanClientDocName(d.name),
|
||||
}));
|
||||
const matched = matchAccountsToTenants(tenants, accounts, {
|
||||
const matched = matchAccountsToTenants(expanded, accounts, {
|
||||
threshold: 50,
|
||||
ambiguousGap: 8,
|
||||
});
|
||||
return matched.map((m) => ({
|
||||
docId: m.accountId || '',
|
||||
docName:
|
||||
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
|
||||
tenantId: m.status === 'matched' ? m.tenantId : null,
|
||||
tenantName: m.status === 'matched' ? m.tenantName : null,
|
||||
score: m.score,
|
||||
status: m.status,
|
||||
}));
|
||||
// Deduplicate by original tenant id (aliases create duplicate rows)
|
||||
const byTenant = new Map<string, (typeof matched)[number]>();
|
||||
for (const m of matched) {
|
||||
if (m.status !== 'matched') continue;
|
||||
const prev = byTenant.get(m.tenantId);
|
||||
if (!prev || m.score > prev.score) byTenant.set(m.tenantId, m);
|
||||
}
|
||||
return tenants.map((t) => {
|
||||
const m = byTenant.get(t.id);
|
||||
if (m) {
|
||||
return {
|
||||
docId: m.accountId || '',
|
||||
docName:
|
||||
docs.find((d) => d.id === m.accountId)?.name || m.accountName || '',
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
score: m.score,
|
||||
status: 'matched' as const,
|
||||
};
|
||||
}
|
||||
return {
|
||||
docId: '',
|
||||
docName: '',
|
||||
tenantId: null,
|
||||
tenantName: null,
|
||||
score: 0,
|
||||
status: 'unmatched' as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Best single-doc match for one tenant (manual zoom). */
|
||||
@@ -524,19 +549,57 @@ export function bestDocForTenant(
|
||||
tenant: { name: string; slug: string },
|
||||
docs: ShDoc[]
|
||||
): { doc: ShDoc; score: number } | null {
|
||||
const names = tenantMatchNames(tenant);
|
||||
let best: { doc: ShDoc; score: number } | null = null;
|
||||
for (const d of docs) {
|
||||
const score = scoreNameToTenant(
|
||||
cleanClientDocName(d.name),
|
||||
tenant.name,
|
||||
tenant.slug
|
||||
);
|
||||
const cleaned = cleanClientDocName(d.name);
|
||||
let score = 0;
|
||||
for (const n of names) {
|
||||
score = Math.max(
|
||||
score,
|
||||
scoreNameToTenant(cleaned, n, tenant.slug)
|
||||
);
|
||||
}
|
||||
if (!best || score > best.score) best = { doc: d, score };
|
||||
}
|
||||
if (!best || best.score < 50) return null;
|
||||
if (!best || best.score < 75) return null;
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Extra names so short brands match full Superhuman / Drive titles. */
|
||||
const TENANT_DOC_ALIASES: Record<string, string[]> = {
|
||||
bps: ['Blue Parrot School', 'Blue Parrot', 'BPS'],
|
||||
'agua-miia': ['Agua Miia', 'Aguamiia', 'Agua'],
|
||||
'brava-dermoestetica': ['Brava', 'Brava Dermoestética'],
|
||||
andala: ['Andala'],
|
||||
'loybee-app': ['LoyBee', 'Loybee', 'LoyBee App'],
|
||||
noberplast: ['Noberplast'],
|
||||
'virginia-molet': ['Virginia Molet', 'Virginia'],
|
||||
'4ventures-hub': ['4Ventures', '4VENTURES'],
|
||||
'delgado-abogados': ['Delgado Abogados', 'Delgado'],
|
||||
'equinsa-parking': ['Equinsa', 'Equinsa Parking'],
|
||||
'consigue-tus-papeles': ['Consigue Tus Papeles', 'CTP'],
|
||||
'talento-y-deporte': ['Talento y Deporte', 'Talento'],
|
||||
'la-freseria': ['La Fresería', 'Fresería', 'Freseria'],
|
||||
gravitae: ['Gravitae'],
|
||||
menttum: ['Menttum'],
|
||||
'wine-advisors': ['Wine Advisors', 'Wine'],
|
||||
ecp: ['ECP'],
|
||||
mcd: ['MCD'],
|
||||
'fbm-abogados': ['FBM Abogados', 'FBM', 'Herencias'],
|
||||
'clinica-sonrisas': ['Clínica Dental Sonrisas', 'Sonrisas', 'Clinica Sonrisas'],
|
||||
'sumar-inversion': ['Sumar Inversión', 'Sumar'],
|
||||
};
|
||||
|
||||
function tenantMatchNames(tenant: { name: string; slug: string }): string[] {
|
||||
const out = [tenant.name];
|
||||
const aliases = TENANT_DOC_ALIASES[tenant.slug.toLowerCase()] || [];
|
||||
for (const a of aliases) {
|
||||
if (!out.some((x) => normalizeName(x) === normalizeName(a))) out.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type DriveDocHit = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -603,7 +666,25 @@ export async function findSeguimientoGoogleDoc(
|
||||
return scoreDriveHits(files2, cleaned);
|
||||
}
|
||||
const files = ((await r.json()) as { files?: DriveDocHit[] }).files || [];
|
||||
return scoreDriveHits(files, cleaned);
|
||||
const scored = scoreDriveHits(files, cleaned);
|
||||
if (scored) return scored;
|
||||
|
||||
// Also search «Seguimiento …» without «Semanal» (e.g. «Seguimiento BPS»)
|
||||
const p2 = new URLSearchParams({
|
||||
q: "name contains 'Seguimiento' and mimeType='application/vnd.google-apps.document' and trashed=false",
|
||||
fields: 'files(id,name,webViewLink,modifiedTime)',
|
||||
pageSize: '50',
|
||||
corpora: 'allDrives',
|
||||
includeItemsFromAllDrives: 'true',
|
||||
supportsAllDrives: 'true',
|
||||
});
|
||||
const r2 = await fetch(`https://www.googleapis.com/drive/v3/files?${p2}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r2.ok) return null;
|
||||
const files2 = ((await r2.json()) as { files?: DriveDocHit[] }).files || [];
|
||||
return scoreDriveHits(files2, cleaned);
|
||||
}
|
||||
|
||||
function scoreDriveHits(
|
||||
@@ -613,7 +694,11 @@ function scoreDriveHits(
|
||||
let best: { file: DriveDocHit; score: number } | null = null;
|
||||
for (const f of files) {
|
||||
const fn = normalizeName(f.name || '');
|
||||
let score = /seguimiento\s+semanal/.test(fn) ? 20 : 0;
|
||||
let score = /seguimiento\s+semanal/.test(fn)
|
||||
? 20
|
||||
: /\bseguimiento\b/.test(fn)
|
||||
? 10
|
||||
: 0;
|
||||
for (const h of cleaned) {
|
||||
const hn = normalizeName(h);
|
||||
if (!hn) continue;
|
||||
@@ -624,10 +709,10 @@ function scoreDriveHits(
|
||||
.split(' ')
|
||||
.filter(
|
||||
(t) =>
|
||||
t.length > 3 && t !== 'seguimiento' && t !== 'semanal'
|
||||
t.length > 2 && t !== 'seguimiento' && t !== 'semanal'
|
||||
);
|
||||
const hits = tokens.filter((t) => fn.includes(t)).length;
|
||||
if (tokens.length && hits / tokens.length >= 0.6) {
|
||||
if (tokens.length && hits / tokens.length >= 0.5) {
|
||||
score = Math.max(score, 70 + hits * 5);
|
||||
}
|
||||
}
|
||||
@@ -742,20 +827,30 @@ export async function buildSeguimientoRowsForDoc(opts: {
|
||||
const now = new Date().toISOString();
|
||||
const rows: SeguimientoSyncRow[] = [];
|
||||
|
||||
// 1) Google Doc «Seguimiento Semanal …» — HTML completo partido por fecha
|
||||
// 1) Google Doc «Seguimiento Semanal …» / «Seguimiento {cliente}» — HTML por día
|
||||
const slugGuess = /\bbps\b|parrot/i.test(opts.tenantName || '')
|
||||
? 'bps'
|
||||
: (opts.tenantName || '').toLowerCase().replace(/\s+/g, '-');
|
||||
const aliasHints = opts.tenantName
|
||||
? tenantMatchNames({ name: opts.tenantName, slug: slugGuess })
|
||||
: ['BPS', 'Blue Parrot School'];
|
||||
const nameHints = [
|
||||
...roots.filter((r) => SEGUIMIENTO_RE.test(r.name)).map((r) => r.name),
|
||||
opts.tenantName ? `Seguimiento Semanal ${opts.tenantName}` : '',
|
||||
opts.tenantName ? `Seguimiento ${opts.tenantName}` : '',
|
||||
...aliasHints.flatMap((n) => [
|
||||
`Seguimiento Semanal ${n}`,
|
||||
`Seguimiento ${n}`,
|
||||
]),
|
||||
'Seguimiento BPS',
|
||||
].filter(Boolean);
|
||||
|
||||
if (exportBodies && nameHints.length) {
|
||||
try {
|
||||
const hit = await findSeguimientoGoogleDoc(nameHints);
|
||||
if (hit) {
|
||||
const html = await exportGoogleDocHtml(hit.id);
|
||||
rows.push(
|
||||
...dayRowsFromHtml({
|
||||
let dayRows: SeguimientoSyncRow[] = [];
|
||||
try {
|
||||
const html = await exportGoogleDocHtml(hit.id);
|
||||
dayRows = dayRowsFromHtml({
|
||||
tenantId: opts.tenantId,
|
||||
html,
|
||||
fileId: hit.id,
|
||||
@@ -764,8 +859,37 @@ export async function buildSeguimientoRowsForDoc(opts: {
|
||||
`https://docs.google.com/document/d/${hit.id}/edit`,
|
||||
maxEntries,
|
||||
now,
|
||||
})
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
// Docs grandes: HTML export 403 «too large» → plain text
|
||||
const plain = await exportGoogleDocPlain(hit.id);
|
||||
const sections = splitNotesByDay(plain).slice(0, maxEntries);
|
||||
dayRows = sections.map((sec) => ({
|
||||
tenant_id: opts.tenantId,
|
||||
source: 'superhuman' as const,
|
||||
entry_type: 'meeting' as const,
|
||||
external_id: `gdoc:${hit.id}:${sec.dateIso}`,
|
||||
title: `Seguimiento · ${sec.label}`,
|
||||
summary:
|
||||
sec.body
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^[\s*•\-]+/, '').trim())
|
||||
.find((l) => l.length > 8)
|
||||
?.slice(0, 220) || null,
|
||||
body_md: `<pre>${sec.body
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')}</pre>`,
|
||||
browser_link:
|
||||
hit.webViewLink ||
|
||||
`https://docs.google.com/document/d/${hit.id}/edit`,
|
||||
metrics: parseMetricsFromMarkdown(sec.body),
|
||||
occurred_at: `${sec.dateIso}T12:00:00.000Z`,
|
||||
synced_at: now,
|
||||
updated_at: now,
|
||||
}));
|
||||
}
|
||||
rows.push(...dayRows);
|
||||
}
|
||||
} catch {
|
||||
// fall through to canvas pages
|
||||
@@ -905,3 +1029,141 @@ export async function patchSuperhumanIntegration(
|
||||
export function docDisplayHint(name: string): string {
|
||||
return normalizeName(cleanClientDocName(name));
|
||||
}
|
||||
|
||||
export type SuperhumanSyncResult = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
docId: string | null;
|
||||
docName: string | null;
|
||||
upserted: number;
|
||||
status: 'synced' | 'unmatched' | 'error';
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/** Sync seguimiento for all (or one) tenants with Superhuman/Drive notes. */
|
||||
export async function syncAllSuperhumanTenants(opts?: {
|
||||
tenantId?: string | null;
|
||||
maxEntries?: number;
|
||||
exportBodies?: boolean;
|
||||
}): Promise<{ synced: number; results: SuperhumanSyncResult[] }> {
|
||||
if (!superhumanConfigured()) {
|
||||
throw new Error('Falta SUPERHUMAN_DOCS_API_KEY');
|
||||
}
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
|
||||
const docs = await listDocs();
|
||||
const tRes = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) throw new Error(await tRes.text());
|
||||
let tenants = (await tRes.json()) as {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}[];
|
||||
if (opts?.tenantId) {
|
||||
tenants = tenants.filter((t) => t.id === opts.tenantId);
|
||||
}
|
||||
|
||||
const maxEntries = Math.min(opts?.maxEntries ?? 100, 150);
|
||||
const exportBodies = opts?.exportBodies !== false;
|
||||
const results: SuperhumanSyncResult[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
let docId: string | null = null;
|
||||
let docName: string | null = null;
|
||||
|
||||
const integUrl = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${SUPERHUMAN_PROVIDER}&select=external_account_id,meta,label`;
|
||||
const integR = await fetch(integUrl, { headers, cache: 'no-store' });
|
||||
const integRows = integR.ok
|
||||
? ((await integR.json()) as {
|
||||
external_account_id?: string | null;
|
||||
meta?: { doc_name?: string };
|
||||
}[])
|
||||
: [];
|
||||
if (integRows[0]?.external_account_id) {
|
||||
docId = integRows[0].external_account_id;
|
||||
docName =
|
||||
integRows[0].meta?.doc_name ||
|
||||
docs.find((d) => d.id === docId)?.name ||
|
||||
null;
|
||||
} else {
|
||||
const hit = bestDocForTenant(tenant, docs);
|
||||
if (hit) {
|
||||
docId = hit.doc.id;
|
||||
docName = hit.doc.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!docId) {
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'disconnected',
|
||||
error_message: 'Sin doc Superhuman matched por nombre',
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId: null,
|
||||
docName: null,
|
||||
upserted: 0,
|
||||
status: 'unmatched',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await buildSeguimientoRowsForDoc({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
maxEntries,
|
||||
exportBodies,
|
||||
});
|
||||
const upserted = await upsertSeguimientoRows(rows);
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'connected',
|
||||
external_account_id: docId,
|
||||
label: docName || 'Superhuman Docs',
|
||||
last_sync_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
meta: {
|
||||
doc_name: docName,
|
||||
entries: upserted,
|
||||
synced_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
docName,
|
||||
upserted,
|
||||
status: 'synced',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'sync failed';
|
||||
await patchSuperhumanIntegration(tenant.id, {
|
||||
status: 'error',
|
||||
external_account_id: docId,
|
||||
error_message: msg.slice(0, 400),
|
||||
});
|
||||
results.push({
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
docId,
|
||||
docName,
|
||||
upserted: 0,
|
||||
status: 'error',
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
synced: results.filter((r) => r.status === 'synced').length,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user