Files
4ventures-client-portal/src/lib/google-organic.ts
4Ventures cf27eba74d 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>
2026-09-28 10:23:36 +02:00

758 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** GA4 + Search Console discover & sync using agency Google OAuth (marketing@). */
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { getValidAccessToken } from '@/lib/google-drive';
export type Ga4Property = {
propertyId: string; // properties/123
displayName: string;
accountName: string;
measurementId: string | null; // G-XXXX
};
export type GscSite = {
siteUrl: string;
permissionLevel: string;
};
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
export async function listGa4Properties(): Promise<Ga4Property[]> {
const token = await getValidAccessToken();
const out: Ga4Property[] = [];
let pageToken: string | undefined;
do {
const url = new URL(
'https://analyticsadmin.googleapis.com/v1beta/accountSummaries'
);
url.searchParams.set('pageSize', '200');
if (pageToken) url.searchParams.set('pageToken', pageToken);
const r = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!r.ok) {
throw new Error(`GA4 Admin: ${(await r.text()).slice(0, 300)}`);
}
const j = (await r.json()) as {
accountSummaries?: {
displayName?: string;
propertySummaries?: { property?: string; displayName?: string }[];
}[];
nextPageToken?: string;
};
for (const acc of j.accountSummaries || []) {
for (const p of acc.propertySummaries || []) {
if (!p.property) continue;
const measurementId = await fetchMeasurementId(token, p.property);
out.push({
propertyId: p.property,
displayName: p.displayName || p.property,
accountName: acc.displayName || '',
measurementId,
});
}
}
pageToken = j.nextPageToken;
} while (pageToken);
return out;
}
async function fetchMeasurementId(
token: string,
propertyResource: string
): Promise<string | null> {
// propertyResource = "properties/123456"
const r = await fetch(
`https://analyticsadmin.googleapis.com/v1beta/${propertyResource}/dataStreams`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) return null;
const j = (await r.json()) as {
dataStreams?: {
webStreamData?: { measurementId?: string };
type?: string;
}[];
};
for (const s of j.dataStreams || []) {
const mid = s.webStreamData?.measurementId;
if (mid) return mid;
}
return null;
}
export async function listGscSites(): Promise<GscSite[]> {
const token = await getValidAccessToken();
const r = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!r.ok) {
throw new Error(`GSC: ${(await r.text()).slice(0, 300)}`);
}
const j = (await r.json()) as {
siteEntry?: { siteUrl?: string; permissionLevel?: string }[];
};
return (j.siteEntry || [])
.filter((s) => s.siteUrl)
.map((s) => ({
siteUrl: s.siteUrl!,
permissionLevel: s.permissionLevel || '',
}));
}
function normName(s: string): string {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function scoreNames(a: string, b: string): number {
const x = normName(a);
const y = normName(b);
if (!x || !y) return 0;
if (x === y) return 100;
if (x.includes(y) || y.includes(x)) return 80;
const xt = new Set(x.split(' ').filter(Boolean));
const yt = new Set(y.split(' ').filter(Boolean));
let hit = 0;
for (const t of xt) if (yt.has(t)) hit += 1;
if (!xt.size || !yt.size) return 0;
return Math.round((100 * hit) / Math.max(xt.size, yt.size));
}
export type OrganicMatch = {
tenantId: string;
tenantName: string;
tenantSlug: string;
ga4?: {
propertyId: string;
measurementId: string | null;
displayName: string;
score: number;
} | null;
gsc?: { siteUrl: string; score: number } | null;
};
export async function matchOrganicToTenants(
tenants: { id: string; name: string; slug: string }[],
ga4: Ga4Property[],
gsc: GscSite[]
): Promise<OrganicMatch[]> {
return tenants.map((t) => {
let bestGa: OrganicMatch['ga4'] = null;
for (const p of ga4) {
const s = Math.max(
scoreNames(t.name, p.displayName),
scoreNames(t.slug, p.displayName),
scoreNames(t.name, p.accountName)
);
if (!bestGa || s > bestGa.score) {
bestGa = {
propertyId: p.propertyId,
measurementId: p.measurementId,
displayName: p.displayName,
score: s,
};
}
}
if (bestGa && bestGa.score < 50) bestGa = null;
let bestGsc: OrganicMatch['gsc'] = null;
for (const s of gsc) {
const url = s.siteUrl.replace(/^sc-domain:/, '').replace(/\/$/, '');
const score = Math.max(
scoreNames(t.name, url),
scoreNames(t.slug, url),
scoreNames(t.name, s.siteUrl)
);
if (!bestGsc || score > bestGsc.score) {
bestGsc = { siteUrl: s.siteUrl, score };
}
}
if (bestGsc && bestGsc.score < 40) bestGsc = null;
return {
tenantId: t.id,
tenantName: t.name,
tenantSlug: t.slug,
ga4: bestGa,
gsc: bestGsc,
};
});
}
/** Apply matches → integrations ga4 / search_console connected. */
export async function applyOrganicMatches(
matches: OrganicMatch[]
): Promise<{ ga4: number; gsc: number }> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
let ga4 = 0;
let gsc = 0;
const base = supabaseUrl();
for (const m of matches) {
if (m.ga4) {
const accountId = m.ga4.measurementId || m.ga4.propertyId;
const ok = await upsertIntegration(base, headers, m.tenantId, 'ga4', {
status: 'connected',
external_account_id: accountId,
label: m.ga4.displayName,
meta: {
property_id: m.ga4.propertyId,
measurement_id: m.ga4.measurementId,
matched_at: new Date().toISOString(),
},
// last_sync_at solo tras volcar organic_daily (no al emparejar)
error_message: null,
});
if (ok) ga4 += 1;
}
if (m.gsc) {
const ok = await upsertIntegration(
base,
headers,
m.tenantId,
'search_console',
{
status: 'connected',
external_account_id: m.gsc.siteUrl,
label: m.gsc.siteUrl,
meta: {
site_url: m.gsc.siteUrl,
matched_at: new Date().toISOString(),
},
error_message: null,
}
);
if (ok) gsc += 1;
}
}
return { ga4, gsc };
}
async function upsertIntegration(
base: string,
headers: { apikey: string; Authorization: string },
tenantId: string,
provider: string,
patch: Record<string, unknown>
): Promise<boolean> {
const r = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(patch),
}
);
if (r.ok) return true;
const ins = await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates',
},
body: JSON.stringify({
tenant_id: tenantId,
provider,
enabled: true,
...patch,
}),
});
return ins.ok;
}
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
export async function fetchGscTopRows(opts: {
siteUrl: string;
startDate: string;
endDate: string;
dimension: 'query' | 'page';
rowLimit?: number;
}): Promise<
{ key: string; clicks: number; impressions: number; ctr: number; position: number }[]
> {
const token = await getValidAccessToken();
const site = encodeURIComponent(opts.siteUrl);
const r = await fetch(
`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate: opts.startDate,
endDate: opts.endDate,
dimensions: [opts.dimension],
rowLimit: opts.rowLimit ?? 25,
}),
}
);
if (!r.ok) throw new Error(`GSC ${opts.dimension}: ${(await r.text()).slice(0, 160)}`);
const j = (await r.json()) as {
rows?: {
keys?: string[];
clicks?: number;
impressions?: number;
ctr?: number;
position?: number;
}[];
};
return (j.rows || []).map((row) => ({
key: row.keys?.[0] || '',
clicks: n(row.clicks),
impressions: n(row.impressions),
ctr: row.ctr != null ? Math.round(n(row.ctr) * 10000) / 100 : 0,
position: row.position != null ? Math.round(n(row.position) * 10) / 10 : 0,
}));
}
export async function fetchGa4TopLandings(opts: {
propertyId: string;
startDate: string;
endDate: string;
rowLimit?: number;
}): Promise<{ page: string; sessions: number; users: number }[]> {
const token = await getValidAccessToken();
const prop = opts.propertyId.replace(/^properties\//, '');
const r = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dateRanges: [{ startDate: opts.startDate, endDate: opts.endDate }],
dimensions: [{ name: 'landingPagePlusQueryString' }],
metrics: [{ name: 'sessions' }, { name: 'totalUsers' }],
dimensionFilter: {
filter: {
fieldName: 'sessionDefaultChannelGroup',
stringFilter: { value: 'Organic Search' },
},
},
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
limit: opts.rowLimit ?? 20,
}),
}
);
if (!r.ok) throw new Error(`GA4 landings: ${(await r.text()).slice(0, 160)}`);
const j = (await r.json()) as {
rows?: {
dimensionValues?: { value?: string }[];
metricValues?: { value?: string }[];
}[];
};
return (j.rows || []).map((row) => ({
page: row.dimensionValues?.[0]?.value || '/',
sessions: n(row.metricValues?.[0]?.value),
users: n(row.metricValues?.[1]?.value),
}));
}
/** Sync last N days of GSC + GA4 into organic_daily for one tenant. */
export async function syncOrganicTenant(opts: {
tenantId: string;
ga4PropertyId?: string | null; // properties/123
gscSiteUrl?: string | null;
days?: number;
}): Promise<{ daysWritten: number; warning?: string }> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const days = opts.days ?? 28;
const token = await getValidAccessToken();
const until = new Date();
until.setUTCDate(until.getUTCDate() - 1); // yesterday
const since = new Date(until);
since.setUTCDate(since.getUTCDate() - (days - 1));
const start = isoDay(since);
const end = isoDay(until);
const byDay = new Map<
string,
{
gsc_clicks: number;
gsc_impressions: number;
gsc_ctr: number | null;
gsc_avg_position: number | null;
ga4_sessions: number;
ga4_users: number;
ga4_organic_sessions: number;
ga4_conversions: number;
}
>();
function ensure(day: string) {
if (!byDay.has(day)) {
byDay.set(day, {
gsc_clicks: 0,
gsc_impressions: 0,
gsc_ctr: null,
gsc_avg_position: null,
ga4_sessions: 0,
ga4_users: 0,
ga4_organic_sessions: 0,
ga4_conversions: 0,
});
}
return byDay.get(day)!;
}
const warnings: string[] = [];
if (opts.gscSiteUrl) {
try {
const site = encodeURIComponent(opts.gscSiteUrl);
const r = await fetch(
`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate: start,
endDate: end,
dimensions: ['date'],
rowLimit: 1000,
}),
}
);
if (!r.ok) {
warnings.push(`GSC ${(await r.text()).slice(0, 120)}`);
} else {
const j = (await r.json()) as {
rows?: {
keys?: string[];
clicks?: number;
impressions?: number;
ctr?: number;
position?: number;
}[];
};
for (const row of j.rows || []) {
const day = (row.keys?.[0] || '').slice(0, 10);
if (!day) continue;
const cur = ensure(day);
cur.gsc_clicks = n(row.clicks);
cur.gsc_impressions = n(row.impressions);
cur.gsc_ctr =
row.ctr != null ? Math.round(n(row.ctr) * 10000) / 100 : null;
cur.gsc_avg_position =
row.position != null ? Math.round(n(row.position) * 10) / 10 : null;
}
}
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'gsc error');
}
}
if (opts.ga4PropertyId) {
try {
const prop = opts.ga4PropertyId.replace(/^properties\//, '');
// Totals by day
const r = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dateRanges: [{ startDate: start, endDate: end }],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'sessions' },
{ name: 'totalUsers' },
{ name: 'conversions' },
],
}),
}
);
if (!r.ok) {
warnings.push(`GA4 ${(await r.text()).slice(0, 120)}`);
} else {
const j = (await r.json()) as {
rows?: {
dimensionValues?: { value?: string }[];
metricValues?: { value?: string }[];
}[];
};
for (const row of j.rows || []) {
const raw = row.dimensionValues?.[0]?.value || '';
// GA4 date = YYYYMMDD
const day =
raw.length === 8
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
: raw.slice(0, 10);
if (!day) continue;
const cur = ensure(day);
cur.ga4_sessions = n(row.metricValues?.[0]?.value);
cur.ga4_users = n(row.metricValues?.[1]?.value);
cur.ga4_conversions = n(row.metricValues?.[2]?.value);
}
}
// Organic sessions by day
const r2 = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dateRanges: [{ startDate: start, endDate: end }],
dimensions: [{ name: 'date' }, { name: 'sessionDefaultChannelGroup' }],
metrics: [{ name: 'sessions' }],
dimensionFilter: {
filter: {
fieldName: 'sessionDefaultChannelGroup',
stringFilter: { value: 'Organic Search' },
},
},
}),
}
);
if (r2.ok) {
const j = (await r2.json()) as {
rows?: {
dimensionValues?: { value?: string }[];
metricValues?: { value?: string }[];
}[];
};
for (const row of j.rows || []) {
const raw = row.dimensionValues?.[0]?.value || '';
const day =
raw.length === 8
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
: raw.slice(0, 10);
if (!day) continue;
ensure(day).ga4_organic_sessions = n(row.metricValues?.[0]?.value);
}
}
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'ga4 error');
}
}
const rows = [...byDay.entries()].map(([day, v]) => ({
tenant_id: opts.tenantId,
day,
...v,
source: 'sync',
}));
if (!rows.length) {
return {
daysWritten: 0,
warning: warnings[0] || 'Sin filas GA4/GSC en el periodo',
};
}
const upsert = await fetch(
`${supabaseUrl()}/rest/v1/organic_daily?on_conflict=tenant_id,day`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=minimal',
},
body: JSON.stringify(rows),
}
);
if (!upsert.ok) {
const msg = await upsert.text();
throw new Error(
msg.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300)
);
}
return {
daysWritten: rows.length,
warning: warnings[0],
};
}
type IntegRow = {
tenant_id: string;
provider: string;
external_account_id?: string;
meta?: { property_id?: string; measurement_id?: string; site_url?: string };
last_sync_at?: string | null;
};
/** Sync all (or one) tenants with connected GA4/GSC. Rolling window ending yesterday. */
export async function syncAllConnectedOrganic(opts?: {
tenantId?: string | null;
/** Days ending yesterday (default 28; daily cron can use 3–7). */
days?: number;
}): Promise<{
synced: number;
results: { tenantId: string; daysWritten: number; warning?: string }[];
}> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const days = Math.min(90, Math.max(3, opts?.days ?? 28));
const tenantFilter = opts?.tenantId?.trim() || '';
const iRes = await fetch(
`${supabaseUrl()}/rest/v1/integrations?provider=in.(ga4,search_console)&status=eq.connected&select=tenant_id,provider,external_account_id,meta,last_sync_at`,
{ headers, cache: 'no-store' }
);
if (!iRes.ok) throw new Error((await iRes.text()).slice(0, 200));
const integs = (await iRes.json()) as IntegRow[];
const byTenant = new Map<
string,
{ ga4PropertyId?: string; gscSiteUrl?: string; measurementId?: string }
>();
for (const i of integs) {
if (tenantFilter && i.tenant_id !== tenantFilter) continue;
const cur = byTenant.get(i.tenant_id) || {};
if (i.provider === 'ga4') {
cur.ga4PropertyId =
i.meta?.property_id ||
(i.external_account_id?.startsWith('properties/')
? i.external_account_id
: undefined);
if (i.external_account_id?.startsWith('G-')) {
cur.measurementId = i.external_account_id;
}
cur.measurementId = cur.measurementId || i.meta?.measurement_id || undefined;
}
if (i.provider === 'search_console') {
cur.gscSiteUrl = i.meta?.site_url || i.external_account_id || undefined;
}
byTenant.set(i.tenant_id, cur);
}
let ga4Catalog: Ga4Property[] | null = null;
const results: {
tenantId: string;
daysWritten: number;
warning?: string;
}[] = [];
for (const [tenantId, cfg] of byTenant) {
let propId = cfg.ga4PropertyId;
if (!propId && cfg.measurementId?.startsWith('G-')) {
if (!ga4Catalog) {
try {
ga4Catalog = await listGa4Properties();
} catch {
ga4Catalog = [];
}
}
const hit = ga4Catalog.find((p) => p.measurementId === cfg.measurementId);
propId = hit?.propertyId;
}
if (!propId && !cfg.gscSiteUrl) continue;
try {
const r = await syncOrganicTenant({
tenantId,
ga4PropertyId: propId,
gscSiteUrl: cfg.gscSiteUrl,
days,
});
// 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({
tenantId,
daysWritten: 0,
warning: e instanceof Error ? e.message : 'sync error',
});
}
}
return {
synced: results.filter((r) => r.daysWritten > 0).length,
results,
};
}
/** True if we should refresh this tenant (no yesterday row or sync older than maxAgeMs). */
export async function organicNeedsRefresh(
tenantId: string,
maxAgeMs = 6 * 60 * 60 * 1000
): Promise<boolean> {
const headers = serviceHeaders();
if (!headers) return false;
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const y = yesterday.toISOString().slice(0, 10);
const [od, ig] = await Promise.all([
fetch(
`${supabaseUrl()}/rest/v1/organic_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&day=eq.${y}&select=day&limit=1`,
{ headers, cache: 'no-store' }
),
fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=in.(ga4,search_console)&status=eq.connected&select=last_sync_at&limit=2`,
{ headers, cache: 'no-store' }
),
]);
if (od.ok) {
const rows = await od.json();
if (!rows.length) return true;
}
if (ig.ok) {
const rows = (await ig.json()) as { last_sync_at?: string }[];
if (!rows.length) return false; // nothing connected
const latest = rows
.map((r) => (r.last_sync_at ? new Date(r.last_sync_at).getTime() : 0))
.reduce((a, b) => Math.max(a, b), 0);
if (!latest || Date.now() - latest > maxAgeMs) return true;
}
return false;
}