From aded4c35144503b1204988ad473230c13a085e75 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 24 Sep 2026 12:04:05 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20OAuth=20Google=20unificado=20(Drive+GA4?= =?UTF-8?q?+GSC)=20y=20sync=20a=20Org=C3=A1nico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conectar marketing@ con scopes Analytics/GSC; descubrir propiedades, emparejar fichas y sincronizar organic_daily 28d. Co-authored-by: Cursor --- docs/OPERATOR.md | 4 +- src/app/api/admin/google/organic/route.ts | 239 ++++++++++ src/components/AgencyPortfolioBoard.tsx | 2 + src/components/DriveConnectPanel.tsx | 11 +- src/components/GoogleOrganicPanel.tsx | 254 +++++++++++ src/lib/google-drive.ts | 18 +- src/lib/google-organic.ts | 515 ++++++++++++++++++++++ 7 files changed, 1034 insertions(+), 9 deletions(-) create mode 100644 src/app/api/admin/google/organic/route.ts create mode 100644 src/components/GoogleOrganicPanel.tsx create mode 100644 src/lib/google-organic.ts diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 1f0048f..0ccb729 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -8,7 +8,7 @@ - `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21) - Re-aplicar RLS `agent_runs` si hace falta: `sql/agent_runs_rls.sql` (idempotente) - Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py` -- Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → auto-match carpetas por nombre de tenant. Env Dokploy: `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `NEXT_PUBLIC_APP_URL`. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Drive. +- Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → scopes Drive + Analytics readonly + Search Console. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Google. Tras consent: panel **Google orgánico** → Descubrir/emparejar → Sync 28d a `organic_daily`. - Panel control clientes: `sql/agency_control.sql` (`agency_followed` + `integrations.enabled`). Matriz en Agencia + hub por cliente. - Meta/Google Ads discovery: env `META_ACCESS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_REFRESH_TOKEN`, `GOOGLE_ADS_CLIENT_ID/SECRET`. UI: Agencia → Descubrir y emparejar. - Meta App Review: mientras esperas aprobación, usa **Solo Google Ads**. Cuando tengas el system user token, pégalo en Agencia (se guarda en `agency_oauth`, sin redeploy) → Descubrir ambos. @@ -41,7 +41,7 @@ Nunca pegar keys en PDFs ni `.env.example`. ## Pendiente ops - `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM -- OAuth live Meta / Google Ads / GA4 / GSC (hoy: estado manual por tenant en Agencia) +- OAuth live Meta; Google Ads via env; **GA4/GSC** vía OAuth marketing@ (reconectar Google en Agencia + sync orgánico) - IP allowlist Traefik: mantener IPs oficina actuales en `middleware-hardening.yml` ## Verificación diff --git a/src/app/api/admin/google/organic/route.ts b/src/app/api/admin/google/organic/route.ts new file mode 100644 index 0000000..da6b9d6 --- /dev/null +++ b/src/app/api/admin/google/organic/route.ts @@ -0,0 +1,239 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { + isAgencyRole, + serviceHeaders, + supabaseUrl, + userRole, +} from '@/lib/agency'; +import { getAgencyOAuthStatus } from '@/lib/google-drive'; +import { + applyOrganicMatches, + listGa4Properties, + listGscSites, + matchOrganicToTenants, + syncOrganicTenant, +} from '@/lib/google-organic'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 120; + +async function requireAgency() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user || !isAgencyRole(userRole(user))) { + return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) }; + } + const headers = serviceHeaders(); + if (!headers) { + return { + error: NextResponse.json({ error: 'no service role' }, { status: 503 }), + }; + } + return { headers }; +} + +/** GET — list GA4 properties + GSC sites visible to marketing@ token. */ +export async function GET() { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + + const status = await getAgencyOAuthStatus(); + if (!status.connected) { + return NextResponse.json({ + connected: false, + warning: 'Conecta Google (marketing@) en Agencia primero.', + ga4: [], + gsc: [], + }); + } + + try { + const [ga4, gsc] = await Promise.all([ + status.hasAnalytics !== false + ? listGa4Properties().catch((e) => { + throw Object.assign(e, { where: 'ga4' }); + }) + : Promise.resolve([]), + status.hasSearchConsole !== false + ? listGscSites().catch((e) => { + throw Object.assign(e, { where: 'gsc' }); + }) + : Promise.resolve([]), + ]); + + return NextResponse.json({ + connected: true, + email: status.email, + hasAnalytics: status.hasAnalytics, + hasSearchConsole: status.hasSearchConsole, + ga4, + gsc, + warning: + !status.hasAnalytics || !status.hasSearchConsole + ? 'Reconecta Google para conceder scopes Analytics + Search Console (además de Drive).' + : null, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : 'error'; + const scopeHint = msg.toLowerCase().includes('scope') + ? ' Reconecta Google con scopes Analytics + GSC.' + : ''; + return NextResponse.json( + { error: msg + scopeHint, connected: true, email: status.email }, + { status: 500 } + ); + } +} + +/** + * POST + * - { action: 'match', apply?: boolean } → match + optional write integrations + * - { action: 'sync', tenantId?, days? } → sync organic_daily + */ +export async function POST(req: NextRequest) { + const gate = await requireAgency(); + if ('error' in gate && gate.error) return gate.error; + const { headers } = gate as { + headers: { apikey: string; Authorization: string }; + }; + + const body = await req.json().catch(() => ({})); + const action = body.action === 'sync' ? 'sync' : 'match'; + + if (action === 'match') { + try { + const [ga4, gsc, tRes] = await Promise.all([ + listGa4Properties(), + listGscSites(), + fetch(`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`, { + headers, + cache: 'no-store', + }), + ]); + if (!tRes.ok) { + return NextResponse.json( + { error: await tRes.text() }, + { status: 500 } + ); + } + const tenants = (await tRes.json()) as { + id: string; + name: string; + slug: string; + }[]; + const matches = await matchOrganicToTenants(tenants, ga4, gsc); + let applied = { ga4: 0, gsc: 0 }; + if (body.apply !== false) { + applied = await applyOrganicMatches( + matches.filter( + (m) => + (m.ga4 && m.ga4.score >= 50) || (m.gsc && m.gsc.score >= 40) + ) + ); + } + return NextResponse.json({ + ga4Count: ga4.length, + gscCount: gsc.length, + matches, + applied, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : 'error'; + return NextResponse.json( + { + error: + msg + + (msg.toLowerCase().includes('scope') + ? ' → Reconecta Google (scopes Analytics + GSC).' + : ''), + }, + { status: 500 } + ); + } + } + + // sync + const days = Math.min(90, Math.max(7, Number(body.days) || 28)); + let tenantFilter = + typeof body.tenantId === 'string' ? body.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`, + { headers, cache: 'no-store' } + ); + if (!iRes.ok) { + return NextResponse.json({ error: await iRes.text() }, { status: 500 }); + } + const integs = (await iRes.json()) as { + tenant_id: string; + provider: string; + external_account_id?: string; + meta?: { property_id?: string; measurement_id?: string; site_url?: string }; + }[]; + + const byTenant = new Map< + string, + { ga4PropertyId?: string; gscSiteUrl?: 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.provider === 'search_console') { + cur.gscSiteUrl = i.meta?.site_url || i.external_account_id || undefined; + } + byTenant.set(i.tenant_id, cur); + } + + // If ga4 only has G- measurement id, resolve property from discover list + let ga4Catalog: Awaited> | null = null; + const results: { + tenantId: string; + daysWritten: number; + warning?: string; + }[] = []; + + for (const [tenantId, cfg] of byTenant) { + let propId = cfg.ga4PropertyId; + if (!propId && integs.some((i) => i.tenant_id === tenantId && i.provider === 'ga4')) { + const mid = integs.find( + (i) => i.tenant_id === tenantId && i.provider === 'ga4' + )?.external_account_id; + if (mid?.startsWith('G-')) { + if (!ga4Catalog) ga4Catalog = await listGa4Properties(); + const hit = ga4Catalog.find((p) => p.measurementId === mid); + propId = hit?.propertyId; + } + } + if (!propId && !cfg.gscSiteUrl) continue; + try { + const r = await syncOrganicTenant({ + tenantId, + ga4PropertyId: propId, + gscSiteUrl: cfg.gscSiteUrl, + days, + }); + results.push({ tenantId, ...r }); + } catch (e) { + results.push({ + tenantId, + daysWritten: 0, + warning: e instanceof Error ? e.message : 'sync error', + }); + } + } + + return NextResponse.json({ + synced: results.filter((r) => r.daysWritten > 0).length, + results, + }); +} diff --git a/src/components/AgencyPortfolioBoard.tsx b/src/components/AgencyPortfolioBoard.tsx index 0f0cdce..7631889 100644 --- a/src/components/AgencyPortfolioBoard.tsx +++ b/src/components/AgencyPortfolioBoard.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import DriveConnectPanel from '@/components/DriveConnectPanel'; import AdsDiscoverPanel from '@/components/AdsDiscoverPanel'; +import GoogleOrganicPanel from '@/components/GoogleOrganicPanel'; import SuperhumanSyncPanel from '@/components/SuperhumanSyncPanel'; import { AlertTriangle, @@ -181,6 +182,7 @@ export default function AgencyPortfolioBoard() { return (
+ diff --git a/src/components/DriveConnectPanel.tsx b/src/components/DriveConnectPanel.tsx index 7b97b9e..8ec7d6f 100644 --- a/src/components/DriveConnectPanel.tsx +++ b/src/components/DriveConnectPanel.tsx @@ -92,11 +92,12 @@ export default function DriveConnectPanel() {

- Google Drive (agencia) + Google agencia (Drive + GA4 + GSC)

-

- Conecta marketing@4ventures.es. - Emparejamos carpetas con clientes por nombre automáticamente. +

+ Conecta marketing@4ventures.es. + Concede Drive, Analytics y Search Console. Luego usa el panel + orgánico para emparejar y sincronizar.

@@ -121,7 +122,7 @@ export default function DriveConnectPanel() { href="/api/admin/drive/connect" className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-brand-500/90 hover:bg-brand-500 text-white" > - {connected ? 'Reconectar' : 'Conectar Drive'} + {connected ? 'Reconectar Google' : 'Conectar Google'} ) : ( diff --git a/src/components/GoogleOrganicPanel.tsx b/src/components/GoogleOrganicPanel.tsx new file mode 100644 index 0000000..045171f --- /dev/null +++ b/src/components/GoogleOrganicPanel.tsx @@ -0,0 +1,254 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { + Leaf, + Loader2, + RefreshCw, + Search, + Sparkles, +} from 'lucide-react'; + +type MatchRow = { + tenantId: string; + tenantName: string; + tenantSlug: string; + ga4?: { + propertyId: string; + measurementId: string | null; + displayName: string; + score: number; + } | null; + gsc?: { siteUrl: string; score: number } | null; +}; + +export default function GoogleOrganicPanel() { + const [loading, setLoading] = useState(true); + const [connected, setConnected] = useState(false); + const [email, setEmail] = useState(null); + const [hasAnalytics, setHasAnalytics] = useState(false); + const [hasGsc, setHasGsc] = useState(false); + const [ga4Count, setGa4Count] = useState(0); + const [gscCount, setGscCount] = useState(0); + const [matches, setMatches] = useState(null); + const [busy, setBusy] = useState(null); + const [msg, setMsg] = useState(null); + const [err, setErr] = useState(null); + + async function loadStatus() { + setLoading(true); + setErr(null); + const res = await fetch('/api/admin/google/organic'); + const data = await res.json(); + setConnected(Boolean(data.connected)); + setEmail(data.email || null); + setHasAnalytics(Boolean(data.hasAnalytics)); + setHasGsc(Boolean(data.hasSearchConsole)); + setGa4Count((data.ga4 || []).length); + setGscCount((data.gsc || []).length); + if (data.warning) setMsg(data.warning); + if (!res.ok && data.error) setErr(data.error); + setLoading(false); + } + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('drive') === 'ok') { + setMsg( + 'Google reconectado. Pulsa «Descubrir y emparejar» para GA4/GSC.' + ); + } + void loadStatus(); + }, []); + + async function discover(apply: boolean) { + setBusy('match'); + setErr(null); + const res = await fetch('/api/admin/google/organic', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'match', apply }), + }); + const data = await res.json(); + setBusy(null); + if (!res.ok) { + setErr(data.error || 'Discover falló'); + return; + } + setMatches(data.matches || []); + setGa4Count(data.ga4Count || 0); + setGscCount(data.gscCount || 0); + setMsg( + apply + ? `Emparejado · GA4 ${data.applied?.ga4 || 0} · GSC ${data.applied?.gsc || 0} (de ${data.ga4Count} props / ${data.gscCount} sites)` + : `Vistas ${data.ga4Count} props GA4 · ${data.gscCount} sites GSC (sin escribir)` + ); + } + + async function syncAll() { + setBusy('sync'); + setErr(null); + const res = await fetch('/api/admin/google/organic', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'sync', days: 28 }), + }); + const data = await res.json(); + setBusy(null); + if (!res.ok) { + setErr(data.error || 'Sync falló'); + return; + } + const wrote = (data.results || []).reduce( + (a: number, r: { daysWritten?: number }) => a + (r.daysWritten || 0), + 0 + ); + setMsg( + `Sync orgánico: ${data.synced || 0} clientes · ${wrote} días escritos en organic_daily` + ); + } + + if (loading) { + return ( +

+ Google orgánico… +

+ ); + } + + return ( +
+
+
+ +
+

+ Google orgánico · GA4 + Search Console +

+

+ Usa el mismo OAuth de{' '} + marketing@4ventures.es{' '} + (Drive + Analytics + GSC). Empareja propiedades/sites a fichas y + sincroniza a Orgánico. +

+
+
+
+ + {connected ? 'Reconectar Google' : 'Conectar Google'} + + + +
+
+ + {connected ? ( +

+ Conectado{email ? ` · ${email}` : ''} + {hasAnalytics ? ' · Analytics ✓' : ' · Analytics ✗'} + {hasGsc ? ' · GSC ✓' : ' · GSC ✗'} + {ga4Count || gscCount + ? ` · vistas: ${ga4Count} props / ${gscCount} sites` + : ''} +

+ ) : ( +

+ Conecta Google (botón arriba o panel Drive). Debe aceptar scopes de + Analytics y Search Console además de Drive. +

+ )} + + {msg && ( +

+ + {msg} +

+ )} + {err && ( +

+ {err} +

+ )} + + {matches && matches.length > 0 && ( +
+ + + + + + + + + + {matches.map((m) => ( + + + + + + ))} + +
ClienteGA4GSC
+ {m.tenantName} + + {m.ga4 ? ( + <> + {m.ga4.displayName} + + {' '} + · {m.ga4.measurementId || m.ga4.propertyId} · score{' '} + {m.ga4.score} + + + ) : ( + — + )} + + {m.gsc ? ( + <> + {m.gsc.siteUrl} + + {' '} + · score {m.gsc.score} + + + ) : ( + — + )} +
+
+ )} +
+ ); +} diff --git a/src/lib/google-drive.ts b/src/lib/google-drive.ts index 6c81b8a..ff7f497 100644 --- a/src/lib/google-drive.ts +++ b/src/lib/google-drive.ts @@ -3,8 +3,12 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency'; export const DRIVE_PROVIDER = 'google_drive'; + +/** Unified agency Google scopes: Drive + GA4 + Search Console. */ export const DRIVE_SCOPES = [ 'https://www.googleapis.com/auth/drive', + 'https://www.googleapis.com/auth/analytics.readonly', + 'https://www.googleapis.com/auth/webmasters.readonly', 'openid', 'email', 'profile', @@ -166,12 +170,16 @@ export async function getAgencyOAuthStatus(): Promise<{ email?: string | null; expiresAt?: string | null; configured: boolean; + scopes?: string | null; + hasAnalytics?: boolean; + hasSearchConsole?: boolean; + hasDrive?: boolean; }> { const configured = driveConfigured(); const headers = serviceHeaders(); if (!headers) return { connected: false, configured }; const r = await fetch( - `${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=account_email,expires_at,refresh_token,access_token`, + `${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=account_email,expires_at,refresh_token,access_token,scopes`, { headers, cache: 'no-store' } ); if (!r.ok) { @@ -180,16 +188,22 @@ export async function getAgencyOAuthStatus(): Promise<{ configured, }; } - const rows = (await r.json()) as OAuthRow[]; + const rows = (await r.json()) as (OAuthRow & { scopes?: string | null })[]; const row = rows[0]; if (!row?.access_token && !row?.refresh_token) { return { connected: false, configured }; } + const scopes = row.scopes || ''; return { connected: true, email: row.account_email, expiresAt: row.expires_at, configured, + scopes, + hasAnalytics: scopes.includes('analytics'), + hasSearchConsole: + scopes.includes('webmasters') || scopes.includes('searchconsole'), + hasDrive: scopes.includes('drive') || !scopes, }; } diff --git a/src/lib/google-organic.ts b/src/lib/google-organic.ts new file mode 100644 index 0000000..f8aa576 --- /dev/null +++ b/src/lib/google-organic.ts @@ -0,0 +1,515 @@ +/** 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, + }, + last_sync_at: new Date().toISOString(), + 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 }, + last_sync_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); +} + +/** 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], + }; +}