feat: al conectar Google, auto-emparejar Drive/GA4/GSC/Ads en fichas
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>
This commit is contained in:
@@ -292,8 +292,52 @@ export async function listGoogleAdsCustomers(): Promise<NamedAccount[]> {
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
/** Facebook Pages (+ IG business linked) visibles al system user / token 4V2030. */
|
||||
export async function listMetaPages(): Promise<{
|
||||
facebook: NamedAccount[];
|
||||
instagram: NamedAccount[];
|
||||
}> {
|
||||
const token = await metaToken();
|
||||
const facebook: NamedAccount[] = [];
|
||||
const instagram: NamedAccount[] = [];
|
||||
let url: string | null =
|
||||
`${META_GRAPH}/me/accounts?fields=id,name,instagram_business_account{id,username,name}&limit=100&access_token=${encodeURIComponent(token)}`;
|
||||
while (url) {
|
||||
const r = await fetch(url, { cache: 'no-store' });
|
||||
if (!r.ok) {
|
||||
throw new Error(`Meta pages: ${(await r.text()).slice(0, 300)}`);
|
||||
}
|
||||
const j = (await r.json()) as {
|
||||
data?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
instagram_business_account?: {
|
||||
id?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
};
|
||||
}[];
|
||||
paging?: { next?: string };
|
||||
};
|
||||
for (const page of j.data || []) {
|
||||
facebook.push({ id: page.id, name: page.name || page.id });
|
||||
const ig = page.instagram_business_account;
|
||||
if (ig?.id) {
|
||||
instagram.push({
|
||||
id: ig.id,
|
||||
name: ig.username
|
||||
? `@${ig.username}`
|
||||
: ig.name || page.name || ig.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
url = j.paging?.next || null;
|
||||
}
|
||||
return { facebook, instagram };
|
||||
}
|
||||
|
||||
export async function applyProviderMatches(
|
||||
provider: 'meta_ads' | 'google_ads',
|
||||
provider: 'meta_ads' | 'google_ads' | 'facebook' | 'instagram',
|
||||
matches: AccountMatch[],
|
||||
labelFallback: string
|
||||
): Promise<{ linked: number }> {
|
||||
@@ -352,7 +396,14 @@ export async function discoverAndMatch(opts?: {
|
||||
apply?: boolean;
|
||||
providers?: Array<'meta_ads' | 'google_ads'>;
|
||||
}): Promise<{
|
||||
meta?: { accounts: number; linked: number; matches: AccountMatch[]; error?: string };
|
||||
meta?: {
|
||||
accounts: number;
|
||||
linked: number;
|
||||
matches: AccountMatch[];
|
||||
error?: string;
|
||||
pagesLinked?: number;
|
||||
igLinked?: number;
|
||||
};
|
||||
google?: { accounts: number; linked: number; matches: AccountMatch[]; error?: string };
|
||||
}> {
|
||||
const headers = serviceHeaders();
|
||||
@@ -380,15 +431,38 @@ export async function discoverAndMatch(opts?: {
|
||||
linked: 0,
|
||||
matches: [],
|
||||
error:
|
||||
'Meta pendiente: pega el system user token en Agencia cuando App Review lo apruebe',
|
||||
'Meta pendiente: pega el system user token (API 4V2030 / App Review) en Agencia',
|
||||
};
|
||||
} else {
|
||||
const accounts = await listMetaAdAccounts();
|
||||
const matches = matchAccountsToTenants(tenants, accounts);
|
||||
const linked = apply
|
||||
let linked = apply
|
||||
? (await applyProviderMatches('meta_ads', matches, 'Meta Ads')).linked
|
||||
: 0;
|
||||
result.meta = { accounts: accounts.length, linked, matches };
|
||||
let pagesLinked = 0;
|
||||
let igLinked = 0;
|
||||
try {
|
||||
const pages = await listMetaPages();
|
||||
const fbMatches = matchAccountsToTenants(tenants, pages.facebook);
|
||||
const igMatches = matchAccountsToTenants(tenants, pages.instagram);
|
||||
if (apply) {
|
||||
pagesLinked = (
|
||||
await applyProviderMatches('facebook', fbMatches, 'Facebook Page')
|
||||
).linked;
|
||||
igLinked = (
|
||||
await applyProviderMatches('instagram', igMatches, 'Instagram')
|
||||
).linked;
|
||||
}
|
||||
} catch {
|
||||
// pages permission may be missing — Ads still OK
|
||||
}
|
||||
result.meta = {
|
||||
accounts: accounts.length,
|
||||
linked,
|
||||
matches,
|
||||
pagesLinked,
|
||||
igLinked,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
result.meta = {
|
||||
|
||||
207
src/lib/agency-google-bootstrap.ts
Normal file
207
src/lib/agency-google-bootstrap.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user