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:
@@ -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