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>
508 lines
15 KiB
TypeScript
508 lines
15 KiB
TypeScript
/** 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/v18';
|
|
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 token saved in agency_oauth (pasted after App Review), else env. */
|
|
export async function resolveMetaAccessToken(): Promise<string | null> {
|
|
const headers = serviceHeaders();
|
|
if (headers) {
|
|
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) return t;
|
|
}
|
|
}
|
|
return process.env.META_ACCESS_TOKEN?.trim() || null;
|
|
}
|
|
|
|
export async function metaConfigured(): Promise<boolean> {
|
|
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',
|
|
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<string> {
|
|
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<string> {
|
|
const refresh =
|
|
process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() ||
|
|
process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim();
|
|
const clientId =
|
|
process.env.GOOGLE_ADS_CLIENT_ID?.trim() ||
|
|
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim();
|
|
const clientSecret =
|
|
process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() ||
|
|
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim();
|
|
if (!refresh || !clientId || !clientSecret) {
|
|
throw new Error(
|
|
'Faltan GOOGLE_ADS_REFRESH_TOKEN + CLIENT_ID/SECRET (o GOOGLE_OAUTH_*)'
|
|
);
|
|
}
|
|
const body = new URLSearchParams({
|
|
refresh_token: refresh,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
grant_type: 'refresh_token',
|
|
});
|
|
const r = await fetch('https://oauth2.googleapis.com/token', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body,
|
|
});
|
|
if (!r.ok) {
|
|
throw new Error(`Google Ads token: ${(await r.text()).slice(0, 200)}`);
|
|
}
|
|
const j = (await r.json()) as { access_token: string };
|
|
return j.access_token;
|
|
}
|
|
|
|
export async function listMetaAdAccounts(): Promise<NamedAccount[]> {
|
|
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<Record<string, string>> {
|
|
const h: Record<string, string> = {
|
|
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<NamedAccount[]> {
|
|
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<string, NamedAccount>();
|
|
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<ReturnType<typeof discoverAndMatch>> = {};
|
|
|
|
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;
|
|
}
|