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

@@ -3,8 +3,12 @@
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
export const DRIVE_PROVIDER = 'google_drive';
/** Unified agency Google scopes: Drive + GA4 + Search Console. */
export const DRIVE_SCOPES = [
'https://www.googleapis.com/auth/drive',
'https://www.googleapis.com/auth/analytics.readonly',
'https://www.googleapis.com/auth/webmasters.readonly',
'openid',
'email',
'profile',
@@ -166,12 +170,16 @@ export async function getAgencyOAuthStatus(): Promise<{
email?: string | null;
expiresAt?: string | null;
configured: boolean;
scopes?: string | null;
hasAnalytics?: boolean;
hasSearchConsole?: boolean;
hasDrive?: boolean;
}> {
const configured = driveConfigured();
const headers = serviceHeaders();
if (!headers) return { connected: false, configured };
const r = await fetch(
`${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=account_email,expires_at,refresh_token,access_token`,
`${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=account_email,expires_at,refresh_token,access_token,scopes`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
@@ -180,16 +188,22 @@ export async function getAgencyOAuthStatus(): Promise<{
configured,
};
}
const rows = (await r.json()) as OAuthRow[];
const rows = (await r.json()) as (OAuthRow & { scopes?: string | null })[];
const row = rows[0];
if (!row?.access_token && !row?.refresh_token) {
return { connected: false, configured };
}
const scopes = row.scopes || '';
return {
connected: true,
email: row.account_email,
expiresAt: row.expires_at,
configured,
scopes,
hasAnalytics: scopes.includes('analytics'),
hasSearchConsole:
scopes.includes('webmasters') || scopes.includes('searchconsole'),
hasDrive: scopes.includes('drive') || !scopes,
};
}

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

@@ -0,0 +1,515 @@
/** GA4 + Search Console discover & sync using agency Google OAuth (marketing@). */
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { getValidAccessToken } from '@/lib/google-drive';
export type Ga4Property = {
propertyId: string; // properties/123
displayName: string;
accountName: string;
measurementId: string | null; // G-XXXX
};
export type GscSite = {
siteUrl: string;
permissionLevel: string;
};
function n(v: unknown): number {
const x = Number(v);
return Number.isFinite(x) ? x : 0;
}
export async function listGa4Properties(): Promise<Ga4Property[]> {
const token = await getValidAccessToken();
const out: Ga4Property[] = [];
let pageToken: string | undefined;
do {
const url = new URL(
'https://analyticsadmin.googleapis.com/v1beta/accountSummaries'
);
url.searchParams.set('pageSize', '200');
if (pageToken) url.searchParams.set('pageToken', pageToken);
const r = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!r.ok) {
throw new Error(`GA4 Admin: ${(await r.text()).slice(0, 300)}`);
}
const j = (await r.json()) as {
accountSummaries?: {
displayName?: string;
propertySummaries?: { property?: string; displayName?: string }[];
}[];
nextPageToken?: string;
};
for (const acc of j.accountSummaries || []) {
for (const p of acc.propertySummaries || []) {
if (!p.property) continue;
const measurementId = await fetchMeasurementId(token, p.property);
out.push({
propertyId: p.property,
displayName: p.displayName || p.property,
accountName: acc.displayName || '',
measurementId,
});
}
}
pageToken = j.nextPageToken;
} while (pageToken);
return out;
}
async function fetchMeasurementId(
token: string,
propertyResource: string
): Promise<string | null> {
// propertyResource = "properties/123456"
const r = await fetch(
`https://analyticsadmin.googleapis.com/v1beta/${propertyResource}/dataStreams`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) return null;
const j = (await r.json()) as {
dataStreams?: {
webStreamData?: { measurementId?: string };
type?: string;
}[];
};
for (const s of j.dataStreams || []) {
const mid = s.webStreamData?.measurementId;
if (mid) return mid;
}
return null;
}
export async function listGscSites(): Promise<GscSite[]> {
const token = await getValidAccessToken();
const r = await fetch('https://www.googleapis.com/webmasters/v3/sites', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (!r.ok) {
throw new Error(`GSC: ${(await r.text()).slice(0, 300)}`);
}
const j = (await r.json()) as {
siteEntry?: { siteUrl?: string; permissionLevel?: string }[];
};
return (j.siteEntry || [])
.filter((s) => s.siteUrl)
.map((s) => ({
siteUrl: s.siteUrl!,
permissionLevel: s.permissionLevel || '',
}));
}
function normName(s: string): string {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function scoreNames(a: string, b: string): number {
const x = normName(a);
const y = normName(b);
if (!x || !y) return 0;
if (x === y) return 100;
if (x.includes(y) || y.includes(x)) return 80;
const xt = new Set(x.split(' ').filter(Boolean));
const yt = new Set(y.split(' ').filter(Boolean));
let hit = 0;
for (const t of xt) if (yt.has(t)) hit += 1;
if (!xt.size || !yt.size) return 0;
return Math.round((100 * hit) / Math.max(xt.size, yt.size));
}
export type OrganicMatch = {
tenantId: string;
tenantName: string;
tenantSlug: string;
ga4?: {
propertyId: string;
measurementId: string | null;
displayName: string;
score: number;
} | null;
gsc?: { siteUrl: string; score: number } | null;
};
export async function matchOrganicToTenants(
tenants: { id: string; name: string; slug: string }[],
ga4: Ga4Property[],
gsc: GscSite[]
): Promise<OrganicMatch[]> {
return tenants.map((t) => {
let bestGa: OrganicMatch['ga4'] = null;
for (const p of ga4) {
const s = Math.max(
scoreNames(t.name, p.displayName),
scoreNames(t.slug, p.displayName),
scoreNames(t.name, p.accountName)
);
if (!bestGa || s > bestGa.score) {
bestGa = {
propertyId: p.propertyId,
measurementId: p.measurementId,
displayName: p.displayName,
score: s,
};
}
}
if (bestGa && bestGa.score < 50) bestGa = null;
let bestGsc: OrganicMatch['gsc'] = null;
for (const s of gsc) {
const url = s.siteUrl.replace(/^sc-domain:/, '').replace(/\/$/, '');
const score = Math.max(
scoreNames(t.name, url),
scoreNames(t.slug, url),
scoreNames(t.name, s.siteUrl)
);
if (!bestGsc || score > bestGsc.score) {
bestGsc = { siteUrl: s.siteUrl, score };
}
}
if (bestGsc && bestGsc.score < 40) bestGsc = null;
return {
tenantId: t.id,
tenantName: t.name,
tenantSlug: t.slug,
ga4: bestGa,
gsc: bestGsc,
};
});
}
/** Apply matches → integrations ga4 / search_console connected. */
export async function applyOrganicMatches(
matches: OrganicMatch[]
): Promise<{ ga4: number; gsc: number }> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
let ga4 = 0;
let gsc = 0;
const base = supabaseUrl();
for (const m of matches) {
if (m.ga4) {
const accountId = m.ga4.measurementId || m.ga4.propertyId;
const ok = await upsertIntegration(base, headers, m.tenantId, 'ga4', {
status: 'connected',
external_account_id: accountId,
label: m.ga4.displayName,
meta: {
property_id: m.ga4.propertyId,
measurement_id: m.ga4.measurementId,
},
last_sync_at: new Date().toISOString(),
error_message: null,
});
if (ok) ga4 += 1;
}
if (m.gsc) {
const ok = await upsertIntegration(
base,
headers,
m.tenantId,
'search_console',
{
status: 'connected',
external_account_id: m.gsc.siteUrl,
label: m.gsc.siteUrl,
meta: { site_url: m.gsc.siteUrl },
last_sync_at: new Date().toISOString(),
error_message: null,
}
);
if (ok) gsc += 1;
}
}
return { ga4, gsc };
}
async function upsertIntegration(
base: string,
headers: { apikey: string; Authorization: string },
tenantId: string,
provider: string,
patch: Record<string, unknown>
): Promise<boolean> {
const r = await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(patch),
}
);
if (r.ok) return true;
const ins = await fetch(`${base}/rest/v1/integrations`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates',
},
body: JSON.stringify({
tenant_id: tenantId,
provider,
enabled: true,
...patch,
}),
});
return ins.ok;
}
function isoDay(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Sync last N days of GSC + GA4 into organic_daily for one tenant. */
export async function syncOrganicTenant(opts: {
tenantId: string;
ga4PropertyId?: string | null; // properties/123
gscSiteUrl?: string | null;
days?: number;
}): Promise<{ daysWritten: number; warning?: string }> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const days = opts.days ?? 28;
const token = await getValidAccessToken();
const until = new Date();
until.setUTCDate(until.getUTCDate() - 1); // yesterday
const since = new Date(until);
since.setUTCDate(since.getUTCDate() - (days - 1));
const start = isoDay(since);
const end = isoDay(until);
const byDay = new Map<
string,
{
gsc_clicks: number;
gsc_impressions: number;
gsc_ctr: number | null;
gsc_avg_position: number | null;
ga4_sessions: number;
ga4_users: number;
ga4_organic_sessions: number;
ga4_conversions: number;
}
>();
function ensure(day: string) {
if (!byDay.has(day)) {
byDay.set(day, {
gsc_clicks: 0,
gsc_impressions: 0,
gsc_ctr: null,
gsc_avg_position: null,
ga4_sessions: 0,
ga4_users: 0,
ga4_organic_sessions: 0,
ga4_conversions: 0,
});
}
return byDay.get(day)!;
}
const warnings: string[] = [];
if (opts.gscSiteUrl) {
try {
const site = encodeURIComponent(opts.gscSiteUrl);
const r = await fetch(
`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
startDate: start,
endDate: end,
dimensions: ['date'],
rowLimit: 1000,
}),
}
);
if (!r.ok) {
warnings.push(`GSC ${(await r.text()).slice(0, 120)}`);
} else {
const j = (await r.json()) as {
rows?: {
keys?: string[];
clicks?: number;
impressions?: number;
ctr?: number;
position?: number;
}[];
};
for (const row of j.rows || []) {
const day = (row.keys?.[0] || '').slice(0, 10);
if (!day) continue;
const cur = ensure(day);
cur.gsc_clicks = n(row.clicks);
cur.gsc_impressions = n(row.impressions);
cur.gsc_ctr =
row.ctr != null ? Math.round(n(row.ctr) * 10000) / 100 : null;
cur.gsc_avg_position =
row.position != null ? Math.round(n(row.position) * 10) / 10 : null;
}
}
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'gsc error');
}
}
if (opts.ga4PropertyId) {
try {
const prop = opts.ga4PropertyId.replace(/^properties\//, '');
// Totals by day
const r = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dateRanges: [{ startDate: start, endDate: end }],
dimensions: [{ name: 'date' }],
metrics: [
{ name: 'sessions' },
{ name: 'totalUsers' },
{ name: 'conversions' },
],
}),
}
);
if (!r.ok) {
warnings.push(`GA4 ${(await r.text()).slice(0, 120)}`);
} else {
const j = (await r.json()) as {
rows?: {
dimensionValues?: { value?: string }[];
metricValues?: { value?: string }[];
}[];
};
for (const row of j.rows || []) {
const raw = row.dimensionValues?.[0]?.value || '';
// GA4 date = YYYYMMDD
const day =
raw.length === 8
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
: raw.slice(0, 10);
if (!day) continue;
const cur = ensure(day);
cur.ga4_sessions = n(row.metricValues?.[0]?.value);
cur.ga4_users = n(row.metricValues?.[1]?.value);
cur.ga4_conversions = n(row.metricValues?.[2]?.value);
}
}
// Organic sessions by day
const r2 = await fetch(
`https://analyticsdata.googleapis.com/v1beta/properties/${prop}:runReport`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
dateRanges: [{ startDate: start, endDate: end }],
dimensions: [{ name: 'date' }, { name: 'sessionDefaultChannelGroup' }],
metrics: [{ name: 'sessions' }],
dimensionFilter: {
filter: {
fieldName: 'sessionDefaultChannelGroup',
stringFilter: { value: 'Organic Search' },
},
},
}),
}
);
if (r2.ok) {
const j = (await r2.json()) as {
rows?: {
dimensionValues?: { value?: string }[];
metricValues?: { value?: string }[];
}[];
};
for (const row of j.rows || []) {
const raw = row.dimensionValues?.[0]?.value || '';
const day =
raw.length === 8
? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`
: raw.slice(0, 10);
if (!day) continue;
ensure(day).ga4_organic_sessions = n(row.metricValues?.[0]?.value);
}
}
} catch (e) {
warnings.push(e instanceof Error ? e.message : 'ga4 error');
}
}
const rows = [...byDay.entries()].map(([day, v]) => ({
tenant_id: opts.tenantId,
day,
...v,
source: 'sync',
}));
if (!rows.length) {
return {
daysWritten: 0,
warning: warnings[0] || 'Sin filas GA4/GSC en el periodo',
};
}
const upsert = await fetch(
`${supabaseUrl()}/rest/v1/organic_daily?on_conflict=tenant_id,day`,
{
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=minimal',
},
body: JSON.stringify(rows),
}
);
if (!upsert.ok) {
const msg = await upsert.text();
throw new Error(
msg.includes('organic_daily')
? 'Aplicar sql/organic_geo.sql'
: msg.slice(0, 300)
);
}
return {
daysWritten: rows.length,
warning: warnings[0],
};
}