Bootstrap unificado post-OAuth + botón Emparejar todo. Meta discover también intenta FB/IG pages con el token system user (API 4V2030). Co-authored-by: Cursor <cursoragent@cursor.com>
208 lines
5.3 KiB
TypeScript
208 lines
5.3 KiB
TypeScript
/**
|
|
* After agency Google OAuth: match Drive + GA4 + GSC (+ Google Ads if env)
|
|
* into every client ficha and sync Orgánico.
|
|
*/
|
|
|
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
|
import {
|
|
applyMatches,
|
|
getAgencyOAuthStatus,
|
|
listDriveFolders,
|
|
matchFoldersToTenants,
|
|
} from '@/lib/google-drive';
|
|
import {
|
|
applyOrganicMatches,
|
|
listGa4Properties,
|
|
listGscSites,
|
|
matchOrganicToTenants,
|
|
syncAllConnectedOrganic,
|
|
} from '@/lib/google-organic';
|
|
import {
|
|
discoverAndMatch,
|
|
googleAdsConfigured,
|
|
} from '@/lib/ads-discovery';
|
|
|
|
export type GoogleBootstrapResult = {
|
|
ok: boolean;
|
|
email?: string | null;
|
|
drive?: { linked: number; folders: number; ambiguous: number; error?: string };
|
|
organic?: {
|
|
ga4Linked: number;
|
|
gscLinked: number;
|
|
ga4Visible: number;
|
|
gscVisible: number;
|
|
daysWritten: number;
|
|
tenantsSynced: number;
|
|
error?: string;
|
|
};
|
|
googleAds?: {
|
|
linked: number;
|
|
accounts: number;
|
|
error?: string;
|
|
};
|
|
message: string;
|
|
};
|
|
|
|
export async function bootstrapAgencyGoogle(opts?: {
|
|
/** Also pull Google Ads MCC accounts into fichas (needs GOOGLE_ADS_* env). */
|
|
includeGoogleAds?: boolean;
|
|
/** Sync organic_daily after matching (default true). */
|
|
syncOrganic?: boolean;
|
|
organicDays?: number;
|
|
}): Promise<GoogleBootstrapResult> {
|
|
const status = await getAgencyOAuthStatus();
|
|
if (!status.connected) {
|
|
return {
|
|
ok: false,
|
|
message: 'Conecta Google (marketing@) en Agencia primero.',
|
|
};
|
|
}
|
|
|
|
const headers = serviceHeaders();
|
|
if (!headers) {
|
|
return { ok: false, message: 'no service role' };
|
|
}
|
|
|
|
const tRes = await fetch(
|
|
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
|
|
{ headers, cache: 'no-store' }
|
|
);
|
|
if (!tRes.ok) {
|
|
return {
|
|
ok: false,
|
|
email: status.email,
|
|
message: `tenants: ${(await tRes.text()).slice(0, 160)}`,
|
|
};
|
|
}
|
|
const tenants = (await tRes.json()) as {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
}[];
|
|
|
|
const result: GoogleBootstrapResult = {
|
|
ok: true,
|
|
email: status.email,
|
|
message: '',
|
|
};
|
|
|
|
// --- Drive ---
|
|
try {
|
|
const folders = await listDriveFolders();
|
|
const matches = await matchFoldersToTenants(tenants, folders);
|
|
const applied = await applyMatches(matches);
|
|
result.drive = {
|
|
linked: applied.linked,
|
|
folders: folders.length,
|
|
ambiguous: matches.filter((m) => m.status === 'ambiguous').length,
|
|
};
|
|
} catch (e) {
|
|
result.drive = {
|
|
linked: 0,
|
|
folders: 0,
|
|
ambiguous: 0,
|
|
error: e instanceof Error ? e.message : 'drive error',
|
|
};
|
|
}
|
|
|
|
// --- GA4 + GSC ---
|
|
try {
|
|
const [ga4, gsc] = await Promise.all([
|
|
status.hasAnalytics !== false
|
|
? listGa4Properties()
|
|
: Promise.resolve([]),
|
|
status.hasSearchConsole !== false
|
|
? listGscSites()
|
|
: Promise.resolve([]),
|
|
]);
|
|
const matches = await matchOrganicToTenants(tenants, ga4, gsc);
|
|
const applied = await applyOrganicMatches(
|
|
matches.filter(
|
|
(m) =>
|
|
(m.ga4 && m.ga4.score >= 50) || (m.gsc && m.gsc.score >= 40)
|
|
)
|
|
);
|
|
let daysWritten = 0;
|
|
let tenantsSynced = 0;
|
|
if (opts?.syncOrganic !== false) {
|
|
const sync = await syncAllConnectedOrganic({
|
|
days: opts?.organicDays ?? 28,
|
|
});
|
|
daysWritten = sync.results.reduce((a, r) => a + (r.daysWritten || 0), 0);
|
|
tenantsSynced = sync.synced;
|
|
}
|
|
result.organic = {
|
|
ga4Linked: applied.ga4,
|
|
gscLinked: applied.gsc,
|
|
ga4Visible: ga4.length,
|
|
gscVisible: gsc.length,
|
|
daysWritten,
|
|
tenantsSynced,
|
|
};
|
|
} catch (e) {
|
|
result.organic = {
|
|
ga4Linked: 0,
|
|
gscLinked: 0,
|
|
ga4Visible: 0,
|
|
gscVisible: 0,
|
|
daysWritten: 0,
|
|
tenantsSynced: 0,
|
|
error: e instanceof Error ? e.message : 'organic error',
|
|
};
|
|
}
|
|
|
|
// --- Google Ads (separate token / MCC env) ---
|
|
if (opts?.includeGoogleAds !== false && googleAdsConfigured()) {
|
|
try {
|
|
const disc = await discoverAndMatch({
|
|
apply: true,
|
|
providers: ['google_ads'],
|
|
});
|
|
const g = disc.google;
|
|
result.googleAds = {
|
|
linked: g?.linked || 0,
|
|
accounts: g?.accounts || 0,
|
|
error: g?.error,
|
|
};
|
|
} catch (e) {
|
|
result.googleAds = {
|
|
linked: 0,
|
|
accounts: 0,
|
|
error: e instanceof Error ? e.message : 'gads error',
|
|
};
|
|
}
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
if (result.drive) {
|
|
parts.push(
|
|
result.drive.error
|
|
? `Drive: ${result.drive.error}`
|
|
: `Drive ${result.drive.linked}/${result.drive.folders}`
|
|
);
|
|
}
|
|
if (result.organic) {
|
|
parts.push(
|
|
result.organic.error
|
|
? `Orgánico: ${result.organic.error}`
|
|
: `GA4 ${result.organic.ga4Linked} · GSC ${result.organic.gscLinked} · ${result.organic.daysWritten} días sync`
|
|
);
|
|
}
|
|
if (result.googleAds) {
|
|
parts.push(
|
|
result.googleAds.error
|
|
? `Ads: ${result.googleAds.error}`
|
|
: `Google Ads ${result.googleAds.linked}/${result.googleAds.accounts}`
|
|
);
|
|
}
|
|
result.message = parts.join(' · ') || 'Sin cambios';
|
|
result.ok = !result.drive?.error || !result.organic?.error;
|
|
// ok if at least one channel succeeded
|
|
result.ok = Boolean(
|
|
(result.drive && !result.drive.error) ||
|
|
(result.organic && !result.organic.error) ||
|
|
(result.googleAds && !result.googleAds.error)
|
|
);
|
|
return result;
|
|
}
|