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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user