/** Discover Meta Ads + Google Ads accounts for agency and match to tenants. */ import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { type AccountMatch, type NamedAccount, matchAccountsToTenants, } from '@/lib/match'; const META_GRAPH = 'https://graph.facebook.com/v21.0'; const GADS_API = 'https://googleads.googleapis.com/v22'; export const META_OAUTH_PROVIDER = 'meta_ads'; export function metaEnvConfigured(): boolean { return Boolean(process.env.META_ACCESS_TOKEN?.trim()); } export function googleAdsConfigured(): boolean { const hasRefresh = Boolean( process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() || process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim() ); const hasClient = Boolean( (process.env.GOOGLE_ADS_CLIENT_ID?.trim() || process.env.GOOGLE_OAUTH_CLIENT_ID?.trim()) && (process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() || process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim()) ); return Boolean( process.env.GOOGLE_ADS_DEVELOPER_TOKEN?.trim() && hasRefresh && hasClient ); } /** Prefer env META_ACCESS_TOKEN (Dokploy / 4V2030), then agency_oauth paste. */ export async function resolveMetaAccessToken(): Promise { const tokens = await resolveMetaAccessTokens(); return tokens[0] || null; } /** * Candidate Meta tokens. * Prefer agency_oauth (system user with BM access to client ad accounts); * Dokploy META_ACCESS_TOKEN may only reach a subset (historically BPS). */ export async function resolveMetaAccessTokens(): Promise { const out: string[] = []; const headers = serviceHeaders(); if (headers) { try { const r = await fetch( `${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${META_OAUTH_PROVIDER}&select=access_token`, { headers, cache: 'no-store' } ); if (r.ok) { const rows = (await r.json()) as { access_token?: string }[]; const t = rows[0]?.access_token?.trim(); if (t) out.push(t); } } catch { /* ignore */ } } const env = process.env.META_ACCESS_TOKEN?.trim(); if (env && !out.includes(env)) out.push(env); return out; } export async function metaConfigured(): Promise { return Boolean(await resolveMetaAccessToken()); } export async function saveMetaAccessToken(token: string): Promise<{ ok: boolean; name?: string; error?: string; }> { const clean = token.trim(); if (!clean || clean.length < 20) { return { ok: false, error: 'Token demasiado corto' }; } // Validate against Graph const check = await fetch( `${META_GRAPH}/me?fields=id,name&access_token=${encodeURIComponent(clean)}`, { cache: 'no-store' } ); if (!check.ok) { return { ok: false, error: `Token inválido: ${(await check.text()).slice(0, 180)}`, }; } const me = (await check.json()) as { id?: string; name?: string }; const headers = serviceHeaders(); if (!headers) return { ok: false, error: 'no service role' }; const row = { provider: META_OAUTH_PROVIDER, access_token: clean, account_email: me.name || me.id || null, expires_at: null, scopes: 'ads_read,ads_management,business_management,pages_show_list,pages_read_engagement,pages_read_user_content,instagram_basic,instagram_manage_insights', updated_at: new Date().toISOString(), meta: { graph_id: me.id || null, saved_via: 'paste' }, }; const base = `${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${META_OAUTH_PROVIDER}`; let r = await fetch(base, { method: 'PATCH', headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=minimal', }, body: JSON.stringify(row), }); if (!r.ok) { r = await fetch(`${supabaseUrl()}/rest/v1/agency_oauth`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'resolution=merge-duplicates', }, body: JSON.stringify(row), }); } if (!r.ok) { return { ok: false, error: (await r.text()).slice(0, 200) }; } return { ok: true, name: me.name || me.id }; } async function metaToken(): Promise { const t = await resolveMetaAccessToken(); if (!t) throw new Error('Falta token Meta (pega el system user token en Agencia)'); return t; } async function googleAdsAccessToken(): Promise { const { resolveGoogleAdsAccessToken } = await import('@/lib/google-ads-auth'); return resolveGoogleAdsAccessToken(); } export async function listMetaAdAccounts(): Promise { const token = await metaToken(); const out: NamedAccount[] = []; let url: string | null = `${META_GRAPH}/me/adaccounts?fields=id,account_id,name,account_status&limit=200&access_token=${encodeURIComponent(token)}`; while (url) { const r = await fetch(url, { cache: 'no-store' }); if (!r.ok) { throw new Error(`Meta adaccounts: ${(await r.text()).slice(0, 300)}`); } const j = (await r.json()) as { data?: { id: string; account_id?: string; name?: string }[]; paging?: { next?: string }; }; for (const row of j.data || []) { const id = row.account_id ? `act_${row.account_id}` : row.id.startsWith('act_') ? row.id : `act_${row.id}`; out.push({ id, name: row.name || id }); } url = j.paging?.next || null; } return out; } async function gadsHeaders( accessToken: string, loginCustomerId?: string ): Promise> { const h: Record = { Authorization: `Bearer ${accessToken}`, 'developer-token': process.env.GOOGLE_ADS_DEVELOPER_TOKEN!.trim(), 'Content-Type': 'application/json', }; const login = loginCustomerId || process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID?.trim()?.replace(/-/g, ''); if (login) h['login-customer-id'] = login; return h; } export async function listGoogleAdsCustomers(): Promise { const accessToken = await googleAdsAccessToken(); const headers = await gadsHeaders(accessToken); const listRes = await fetch(`${GADS_API}/customers:listAccessibleCustomers`, { method: 'GET', headers, cache: 'no-store', }); if (!listRes.ok) { throw new Error( `Google Ads accessible: ${(await listRes.text()).slice(0, 300)}` ); } const listed = (await listRes.json()) as { resourceNames?: string[] }; const ids = (listed.resourceNames || []).map((rn) => rn.replace('customers/', '') ); const out: NamedAccount[] = []; for (const id of ids) { try { const h = await gadsHeaders(accessToken, id); const r = await fetch( `${GADS_API}/customers/${id}/googleAds:search`, { method: 'POST', headers: h, body: JSON.stringify({ query: 'SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1', }), } ); if (!r.ok) continue; const j = (await r.json()) as { results?: { customer?: { id?: string; descriptiveName?: string; manager?: boolean; }; }[]; }; const c = j.results?.[0]?.customer; if (!c) continue; // Prefer leaf accounts for client match; still include managers with name const name = c.descriptiveName || id; out.push({ id: String(c.id || id), name: c.manager ? `${name} (MCC)` : name, }); // If manager, also pull client accounts if (c.manager) { const clients = await fetch( `${GADS_API}/customers/${id}/googleAds:search`, { method: 'POST', headers: h, body: JSON.stringify({ query: `SELECT customer_client.id, customer_client.descriptive_name, customer_client.manager, customer_client.status FROM customer_client WHERE customer_client.status = 'ENABLED' AND customer_client.manager = FALSE`, }), } ); if (clients.ok) { const cj = (await clients.json()) as { results?: { customerClient?: { id?: string; descriptiveName?: string; }; }[]; }; for (const row of cj.results || []) { const cc = row.customerClient; if (!cc?.id) continue; out.push({ id: String(cc.id), name: cc.descriptiveName || String(cc.id), }); } } } } catch { // skip inaccessible } } // Dedupe by id const map = new Map(); for (const a of out) map.set(a.id, a); 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' | 'facebook' | 'instagram', matches: AccountMatch[], labelFallback: string ): Promise<{ linked: number }> { const headers = serviceHeaders(); if (!headers) throw new Error('no service role'); const base = supabaseUrl(); let linked = 0; for (const m of matches) { if (m.status !== 'matched' || !m.accountId) continue; const patch = { status: 'connected', external_account_id: m.accountId, label: m.accountName || labelFallback, last_sync_at: new Date().toISOString(), error_message: null, enabled: true, meta: { account_name: m.accountName, match_score: m.score, matched_at: new Date().toISOString(), source: 'auto_discover', }, updated_at: new Date().toISOString(), }; const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(m.tenantId)}&provider=eq.${provider}`; let r = await fetch(url, { method: 'PATCH', headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=minimal', }, body: JSON.stringify(patch), }); if (!r.ok) { r = await fetch(`${base}/rest/v1/integrations`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'resolution=merge-duplicates', }, body: JSON.stringify({ tenant_id: m.tenantId, provider, ...patch, }), }); } if (r.ok) linked += 1; } return { linked }; } export async function discoverAndMatch(opts?: { apply?: boolean; providers?: Array<'meta_ads' | 'google_ads'>; }): Promise<{ 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(); if (!headers) throw new Error('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) throw new Error(await tRes.text()); const tenants = (await tRes.json()) as { id: string; name: string; slug: string; }[]; const want = opts?.providers || ['meta_ads', 'google_ads']; const apply = opts?.apply !== false; const result: Awaited> = {}; if (want.includes('meta_ads')) { try { if (!(await metaConfigured())) { result.meta = { accounts: 0, linked: 0, matches: [], error: 'Meta pendiente: pega el system user token (API 4V2030 / App Review) en Agencia', }; } else { const accounts = await listMetaAdAccounts(); const matches = matchAccountsToTenants(tenants, accounts); let linked = apply ? (await applyProviderMatches('meta_ads', matches, 'Meta Ads')).linked : 0; 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 = { accounts: 0, linked: 0, matches: [], error: e instanceof Error ? e.message : 'meta error', }; } } if (want.includes('google_ads')) { try { if (!googleAdsConfigured()) { result.google = { accounts: 0, linked: 0, matches: [], error: 'GOOGLE_ADS_* no configurado', }; } else { const accounts = await listGoogleAdsCustomers(); const matches = matchAccountsToTenants(tenants, accounts); const linked = apply ? ( await applyProviderMatches('google_ads', matches, 'Google Ads') ).linked : 0; result.google = { accounts: accounts.length, linked, matches }; } } catch (e) { result.google = { accounts: 0, linked: 0, matches: [], error: e instanceof Error ? e.message : 'google error', }; } } return result; }