feat: paste Meta token after App Review; Google-only discover
While Meta review is pending, agency can match Google Ads alone and paste the system user token later without a redeploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,8 +9,9 @@ import {
|
||||
|
||||
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 metaConfigured(): boolean {
|
||||
export function metaEnvConfigured(): boolean {
|
||||
return Boolean(process.env.META_ACCESS_TOKEN?.trim());
|
||||
}
|
||||
|
||||
@@ -30,9 +31,90 @@ export function googleAdsConfigured(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function metaToken(): string {
|
||||
const t = process.env.META_ACCESS_TOKEN?.trim();
|
||||
if (!t) throw new Error('Falta META_ACCESS_TOKEN');
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -70,7 +152,7 @@ async function googleAdsAccessToken(): Promise<string> {
|
||||
}
|
||||
|
||||
export async function listMetaAdAccounts(): Promise<NamedAccount[]> {
|
||||
const token = metaToken();
|
||||
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)}`;
|
||||
@@ -292,12 +374,13 @@ export async function discoverAndMatch(opts?: {
|
||||
|
||||
if (want.includes('meta_ads')) {
|
||||
try {
|
||||
if (!metaConfigured()) {
|
||||
if (!(await metaConfigured())) {
|
||||
result.meta = {
|
||||
accounts: 0,
|
||||
linked: 0,
|
||||
matches: [],
|
||||
error: 'META_ACCESS_TOKEN no configurado',
|
||||
error:
|
||||
'Meta pendiente: pega el system user token en Agencia cuando App Review lo apruebe',
|
||||
};
|
||||
} else {
|
||||
const accounts = await listMetaAdAccounts();
|
||||
|
||||
Reference in New Issue
Block a user