/** 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 { 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 { // 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 { 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 { 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 ): Promise { 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 { 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; }