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