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:
@@ -8,7 +8,7 @@
|
|||||||
- `sql/creatives.sql` **aplicado** en prod; RLS activo en tablas de negocio (incl. `agent_runs`, verificado 2026-09-21)
|
- `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)
|
- 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`
|
- 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.
|
- 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/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.
|
- 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
|
## Pendiente ops
|
||||||
|
|
||||||
- `ANTHROPIC_API_KEY` en Dokploy env si se quiere el asistente LLM
|
- `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`
|
- IP allowlist Traefik: mantener IPs oficina actuales en `middleware-hardening.yml`
|
||||||
|
|
||||||
## Verificación
|
## Verificación
|
||||||
|
|||||||
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 Link from 'next/link';
|
||||||
import DriveConnectPanel from '@/components/DriveConnectPanel';
|
import DriveConnectPanel from '@/components/DriveConnectPanel';
|
||||||
import AdsDiscoverPanel from '@/components/AdsDiscoverPanel';
|
import AdsDiscoverPanel from '@/components/AdsDiscoverPanel';
|
||||||
|
import GoogleOrganicPanel from '@/components/GoogleOrganicPanel';
|
||||||
import SuperhumanSyncPanel from '@/components/SuperhumanSyncPanel';
|
import SuperhumanSyncPanel from '@/components/SuperhumanSyncPanel';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -181,6 +182,7 @@ export default function AgencyPortfolioBoard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<DriveConnectPanel />
|
<DriveConnectPanel />
|
||||||
|
<GoogleOrganicPanel />
|
||||||
<AdsDiscoverPanel />
|
<AdsDiscoverPanel />
|
||||||
<SuperhumanSyncPanel />
|
<SuperhumanSyncPanel />
|
||||||
|
|
||||||
|
|||||||
@@ -92,11 +92,12 @@ export default function DriveConnectPanel() {
|
|||||||
<HardDrive className="w-5 h-5 text-brand-400 mt-0.5 shrink-0" />
|
<HardDrive className="w-5 h-5 text-brand-400 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
Google Drive (agencia)
|
Google agencia (Drive + GA4 + GSC)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
<p className="text-xs text-gray-600 mt-0.5 max-w-xl">
|
||||||
Conecta <strong className="text-gray-600">marketing@4ventures.es</strong>.
|
Conecta <strong className="text-gray-800">marketing@4ventures.es</strong>.
|
||||||
Emparejamos carpetas con clientes por nombre automáticamente.
|
Concede Drive, Analytics y Search Console. Luego usa el panel
|
||||||
|
orgánico para emparejar y sincronizar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +122,7 @@ export default function DriveConnectPanel() {
|
|||||||
href="/api/admin/drive/connect"
|
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"
|
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>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[11px] text-amber-400 px-2 py-1.5">
|
<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';
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||||
|
|
||||||
export const DRIVE_PROVIDER = 'google_drive';
|
export const DRIVE_PROVIDER = 'google_drive';
|
||||||
|
|
||||||
|
/** Unified agency Google scopes: Drive + GA4 + Search Console. */
|
||||||
export const DRIVE_SCOPES = [
|
export const DRIVE_SCOPES = [
|
||||||
'https://www.googleapis.com/auth/drive',
|
'https://www.googleapis.com/auth/drive',
|
||||||
|
'https://www.googleapis.com/auth/analytics.readonly',
|
||||||
|
'https://www.googleapis.com/auth/webmasters.readonly',
|
||||||
'openid',
|
'openid',
|
||||||
'email',
|
'email',
|
||||||
'profile',
|
'profile',
|
||||||
@@ -166,12 +170,16 @@ export async function getAgencyOAuthStatus(): Promise<{
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
scopes?: string | null;
|
||||||
|
hasAnalytics?: boolean;
|
||||||
|
hasSearchConsole?: boolean;
|
||||||
|
hasDrive?: boolean;
|
||||||
}> {
|
}> {
|
||||||
const configured = driveConfigured();
|
const configured = driveConfigured();
|
||||||
const headers = serviceHeaders();
|
const headers = serviceHeaders();
|
||||||
if (!headers) return { connected: false, configured };
|
if (!headers) return { connected: false, configured };
|
||||||
const r = await fetch(
|
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' }
|
{ headers, cache: 'no-store' }
|
||||||
);
|
);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -180,16 +188,22 @@ export async function getAgencyOAuthStatus(): Promise<{
|
|||||||
configured,
|
configured,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rows = (await r.json()) as OAuthRow[];
|
const rows = (await r.json()) as (OAuthRow & { scopes?: string | null })[];
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row?.access_token && !row?.refresh_token) {
|
if (!row?.access_token && !row?.refresh_token) {
|
||||||
return { connected: false, configured };
|
return { connected: false, configured };
|
||||||
}
|
}
|
||||||
|
const scopes = row.scopes || '';
|
||||||
return {
|
return {
|
||||||
connected: true,
|
connected: true,
|
||||||
email: row.account_email,
|
email: row.account_email,
|
||||||
expiresAt: row.expires_at,
|
expiresAt: row.expires_at,
|
||||||
configured,
|
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