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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user