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:
239
src/app/api/admin/google/organic/route.ts
Normal file
239
src/app/api/admin/google/organic/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
254
src/components/GoogleOrganicPanel.tsx
Normal file
254
src/components/GoogleOrganicPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
515
src/lib/google-organic.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user