feat: auto-discover Meta and Google Ads accounts for tenants
Agency panel lists accessible ad accounts and matches them to clients by name, same pattern as Drive folders. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
350
src/lib/ads-discovery.ts
Normal file
350
src/lib/ads-discovery.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/** 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 function metaConfigured(): 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
|
||||
);
|
||||
}
|
||||
|
||||
function metaToken(): string {
|
||||
const t = process.env.META_ACCESS_TOKEN?.trim();
|
||||
if (!t) throw new Error('Falta META_ACCESS_TOKEN');
|
||||
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 = 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()];
|
||||
}
|
||||
|
||||
export async function applyProviderMatches(
|
||||
provider: 'meta_ads' | 'google_ads',
|
||||
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 };
|
||||
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 (!metaConfigured()) {
|
||||
result.meta = {
|
||||
accounts: 0,
|
||||
linked: 0,
|
||||
matches: [],
|
||||
error: 'META_ACCESS_TOKEN no configurado',
|
||||
};
|
||||
} else {
|
||||
const accounts = await listMetaAdAccounts();
|
||||
const matches = matchAccountsToTenants(tenants, accounts);
|
||||
const linked = apply
|
||||
? (await applyProviderMatches('meta_ads', matches, 'Meta Ads')).linked
|
||||
: 0;
|
||||
result.meta = { accounts: accounts.length, linked, matches };
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
136
src/lib/match.ts
Normal file
136
src/lib/match.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/** Shared fuzzy match: account/folder name ↔ tenant name/slug. */
|
||||
|
||||
export function normalizeName(raw: string): string {
|
||||
return raw
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function scoreNameToTenant(
|
||||
accountName: string,
|
||||
tenantName: string,
|
||||
tenantSlug: string
|
||||
): number {
|
||||
const f = normalizeName(accountName);
|
||||
const n = normalizeName(tenantName);
|
||||
const s = normalizeName(tenantSlug.replace(/-/g, ' '));
|
||||
if (!f || (!n && !s)) return 0;
|
||||
if (f === n || f === s) return 100;
|
||||
if (n && (f.includes(n) || n.includes(f))) return 85;
|
||||
if (s && (f.includes(s) || s.includes(f))) return 80;
|
||||
const ft = new Set(f.split(' ').filter((t) => t.length > 2));
|
||||
const nt = new Set(
|
||||
[...n.split(' '), ...s.split(' ')].filter((t) => t.length > 2)
|
||||
);
|
||||
if (!ft.size || !nt.size) return 0;
|
||||
let hit = 0;
|
||||
for (const t of nt) if (ft.has(t)) hit += 1;
|
||||
const ratio = hit / nt.size;
|
||||
if (ratio >= 0.8) return 70;
|
||||
if (ratio >= 0.5) return 55;
|
||||
return Math.round(ratio * 50);
|
||||
}
|
||||
|
||||
export type NamedAccount = { id: string; name: string };
|
||||
|
||||
export type AccountMatch = {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
tenantSlug: string;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
score: number;
|
||||
status: 'matched' | 'ambiguous' | 'unmatched';
|
||||
candidates?: { id: string; name: string; score: number }[];
|
||||
};
|
||||
|
||||
export function matchAccountsToTenants(
|
||||
tenants: { id: string; name: string; slug: string }[],
|
||||
accounts: NamedAccount[],
|
||||
opts?: { threshold?: number; ambiguousGap?: number }
|
||||
): AccountMatch[] {
|
||||
const THRESHOLD = opts?.threshold ?? 55;
|
||||
const AMBIGUOUS_GAP = opts?.ambiguousGap ?? 8;
|
||||
|
||||
type Scored = { account: NamedAccount; tenantId: string; score: number };
|
||||
const all: Scored[] = [];
|
||||
for (const t of tenants) {
|
||||
for (const a of accounts) {
|
||||
const score = scoreNameToTenant(a.name, t.name, t.slug);
|
||||
if (score >= THRESHOLD) {
|
||||
all.push({ account: a, tenantId: t.id, score });
|
||||
}
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => b.score - a.score);
|
||||
|
||||
const usedAccounts = new Set<string>();
|
||||
const usedTenants = new Set<string>();
|
||||
const assigned = new Map<string, { account: NamedAccount; score: number }>();
|
||||
|
||||
for (const row of all) {
|
||||
if (usedAccounts.has(row.account.id) || usedTenants.has(row.tenantId)) {
|
||||
continue;
|
||||
}
|
||||
const rivals = all.filter(
|
||||
(x) =>
|
||||
x.tenantId === row.tenantId &&
|
||||
x.account.id !== row.account.id &&
|
||||
!usedAccounts.has(x.account.id) &&
|
||||
row.score - x.score < AMBIGUOUS_GAP &&
|
||||
x.score >= THRESHOLD
|
||||
);
|
||||
if (rivals.length) continue;
|
||||
assigned.set(row.tenantId, { account: row.account, score: row.score });
|
||||
usedAccounts.add(row.account.id);
|
||||
usedTenants.add(row.tenantId);
|
||||
}
|
||||
|
||||
return tenants.map((t) => {
|
||||
const hit = assigned.get(t.id);
|
||||
if (hit) {
|
||||
return {
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
tenantSlug: t.slug,
|
||||
accountId: hit.account.id,
|
||||
accountName: hit.account.name,
|
||||
score: hit.score,
|
||||
status: 'matched' as const,
|
||||
};
|
||||
}
|
||||
const cands = all
|
||||
.filter((x) => x.tenantId === t.id)
|
||||
.slice(0, 5)
|
||||
.map((x) => ({
|
||||
id: x.account.id,
|
||||
name: x.account.name,
|
||||
score: x.score,
|
||||
}));
|
||||
if (cands.length) {
|
||||
return {
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
tenantSlug: t.slug,
|
||||
accountId: null,
|
||||
accountName: null,
|
||||
score: cands[0].score,
|
||||
status: 'ambiguous' as const,
|
||||
candidates: cands,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tenantId: t.id,
|
||||
tenantName: t.name,
|
||||
tenantSlug: t.slug,
|
||||
accountId: null,
|
||||
accountName: null,
|
||||
score: 0,
|
||||
status: 'unmatched' as const,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user