feat: OAuth Google unificado (Drive+GA4+GSC) y sync a Orgánico

Conectar marketing@ con scopes Analytics/GSC; descubrir propiedades,
emparejar fichas y sincronizar organic_daily 28d.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 12:04:05 +02:00
parent 81ede58330
commit aded4c3514
7 changed files with 1034 additions and 9 deletions

View File

@@ -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<ReturnType<typeof listGa4Properties>> | 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,
});
}

View File

@@ -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 (
<div className="space-y-6">
<DriveConnectPanel />
<GoogleOrganicPanel />
<AdsDiscoverPanel />
<SuperhumanSyncPanel />

View File

@@ -92,11 +92,12 @@ export default function DriveConnectPanel() {
<HardDrive className="w-5 h-5 text-brand-400 mt-0.5 shrink-0" />
<div>
<h3 className="text-sm font-semibold text-gray-900">
Google Drive (agencia)
Google agencia (Drive + GA4 + GSC)
</h3>
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
Conecta <strong className="text-gray-600">marketing@4ventures.es</strong>.
Emparejamos carpetas con clientes por nombre automáticamente.
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
Conecta <strong className="text-gray-800">marketing@4ventures.es</strong>.
Concede Drive, Analytics y Search Console. Luego usa el panel
orgánico para emparejar y sincronizar.
</p>
</div>
</div>
@@ -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'}
</a>
) : (
<span className="text-[11px] text-amber-400 px-2 py-1.5">

View File

@@ -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<string | null>(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<MatchRow[] | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(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 (
<p className="text-sm text-gray-500 flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" /> Google orgánico…
</p>
);
}
return (
<section className="rounded-xl border border-gray-200 bg-white p-5 space-y-4 shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="flex items-start gap-3">
<Leaf className="w-5 h-5 text-lime-600 mt-0.5 shrink-0" />
<div>
<h3 className="text-sm font-semibold text-gray-900">
Google orgánico · GA4 + Search Console
</h3>
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
Usa el mismo OAuth de{' '}
<strong className="text-gray-800">marketing@4ventures.es</strong>{' '}
(Drive + Analytics + GSC). Empareja propiedades/sites a fichas y
sincroniza a Orgánico.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<a
href="/api/admin/drive/connect"
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-brand-500 text-white hover:bg-brand-600"
>
{connected ? 'Reconectar Google' : 'Conectar Google'}
</a>
<button
type="button"
disabled={!connected || busy !== null}
onClick={() => void discover(true)}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 text-gray-800 hover:bg-gray-50 disabled:opacity-40"
>
{busy === 'match' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Search className="w-3.5 h-3.5" />
)}
Descubrir y emparejar
</button>
<button
type="button"
disabled={!connected || busy !== null}
onClick={() => void syncAll()}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-lime-300 text-lime-800 bg-lime-50 hover:bg-lime-100 disabled:opacity-40"
>
{busy === 'sync' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
Sync 28d → Orgánico
</button>
</div>
</div>
{connected ? (
<p className="text-xs text-emerald-700">
Conectado{email ? ` · ${email}` : ''}
{hasAnalytics ? ' · Analytics ✓' : ' · Analytics ✗'}
{hasGsc ? ' · GSC ✓' : ' · GSC ✗'}
{ga4Count || gscCount
? ` · vistas: ${ga4Count} props / ${gscCount} sites`
: ''}
</p>
) : (
<p className="text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Conecta Google (botón arriba o panel Drive). Debe aceptar scopes de
Analytics y Search Console además de Drive.
</p>
)}
{msg && (
<p className="text-xs text-gray-700 flex items-start gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-brand-600 shrink-0 mt-0.5" />
{msg}
</p>
)}
{err && (
<p className="text-xs text-red-700 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{err}
</p>
)}
{matches && matches.length > 0 && (
<div className="overflow-x-auto max-h-80 overflow-y-auto border border-gray-100 rounded-lg">
<table className="w-full text-left text-xs">
<thead className="text-gray-600 border-b border-gray-200 sticky top-0 bg-white">
<tr>
<th className="py-2 px-3 font-semibold">Cliente</th>
<th className="py-2 px-3 font-semibold">GA4</th>
<th className="py-2 px-3 font-semibold">GSC</th>
</tr>
</thead>
<tbody>
{matches.map((m) => (
<tr
key={m.tenantId}
className="border-b border-gray-50 hover:bg-gray-50"
>
<td className="py-2 px-3 font-medium text-gray-900">
{m.tenantName}
</td>
<td className="py-2 px-3 text-gray-700">
{m.ga4 ? (
<>
{m.ga4.displayName}
<span className="text-gray-400">
{' '}
· {m.ga4.measurementId || m.ga4.propertyId} · score{' '}
{m.ga4.score}
</span>
</>
) : (
<span className="text-gray-400">—</span>
)}
</td>
<td className="py-2 px-3 text-gray-700">
{m.gsc ? (
<>
{m.gsc.siteUrl}
<span className="text-gray-400">
{' '}
· score {m.gsc.score}
</span>
</>
) : (
<span className="text-gray-400">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}

View File

@@ -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,
};
}

515
src/lib/google-organic.ts Normal file
View File

@@ -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<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,
},
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<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);
}
/** 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],
};
}