From 6607a02bffd5cfca704162033618b80cb518d6e5 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 22 Sep 2026 21:11:03 +0200 Subject: [PATCH] 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 --- docs/OPERATOR.md | 1 + src/app/api/admin/ads/meta-token/route.ts | 25 +++++ src/app/api/admin/ads/status/route.ts | 8 +- src/components/AdsDiscoverPanel.tsx | 119 +++++++++++++++++----- src/lib/ads-discovery.ts | 97 ++++++++++++++++-- 5 files changed, 215 insertions(+), 35 deletions(-) create mode 100644 src/app/api/admin/ads/meta-token/route.ts diff --git a/docs/OPERATOR.md b/docs/OPERATOR.md index 69e43d1..1f0048f 100644 --- a/docs/OPERATOR.md +++ b/docs/OPERATOR.md @@ -11,6 +11,7 @@ - Google Drive (agencia): `sql/google_drive.sql`. OAuth marketing@ → auto-match carpetas por nombre de tenant. Env Dokploy: `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `NEXT_PUBLIC_APP_URL`. Redirect: `https://app.4ventures.es/api/admin/drive/callback`. UI: Agencia → Conectar Drive. - Panel control clientes: `sql/agency_control.sql` (`agency_followed` + `integrations.enabled`). Matriz en Agencia + hub por cliente. - Meta/Google Ads discovery: env `META_ACCESS_TOKEN`, `GOOGLE_ADS_DEVELOPER_TOKEN`, `GOOGLE_ADS_REFRESH_TOKEN`, `GOOGLE_ADS_CLIENT_ID/SECRET`. UI: Agencia → Descubrir y emparejar. +- Meta App Review: mientras esperas aprobación, usa **Solo Google Ads**. Cuando tengas el system user token, pégalo en Agencia (se guarda en `agency_oauth`, sin redeploy) → Descubrir ambos. ## Login diff --git a/src/app/api/admin/ads/meta-token/route.ts b/src/app/api/admin/ads/meta-token/route.ts new file mode 100644 index 0000000..0ddd157 --- /dev/null +++ b/src/app/api/admin/ads/meta-token/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { isAgencyRole, userRole } from '@/lib/agency'; +import { saveMetaAccessToken } from '@/lib/ads-discovery'; + +export const dynamic = 'force-dynamic'; + +/** POST { token } — save Meta system user token (after App Review). */ +export async function POST(req: NextRequest) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user || !isAgencyRole(userRole(user))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const body = await req.json().catch(() => ({})); + const token = typeof body.token === 'string' ? body.token : ''; + const result = await saveMetaAccessToken(token); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + return NextResponse.json({ ok: true, name: result.name }); +} diff --git a/src/app/api/admin/ads/status/route.ts b/src/app/api/admin/ads/status/route.ts index 9a324ac..6fb5927 100644 --- a/src/app/api/admin/ads/status/route.ts +++ b/src/app/api/admin/ads/status/route.ts @@ -4,6 +4,7 @@ import { isAgencyRole, userRole } from '@/lib/agency'; import { googleAdsConfigured, metaConfigured, + metaEnvConfigured, } from '@/lib/ads-discovery'; export const dynamic = 'force-dynamic'; @@ -18,8 +19,13 @@ export async function GET() { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } + const metaReady = await metaConfigured(); return NextResponse.json({ - meta: { configured: metaConfigured() }, + meta: { + configured: metaReady, + envFallback: metaEnvConfigured(), + pendingReview: !metaReady, + }, googleAds: { configured: googleAdsConfigured() }, }); } diff --git a/src/components/AdsDiscoverPanel.tsx b/src/components/AdsDiscoverPanel.tsx index 6f45b72..5da00ad 100644 --- a/src/components/AdsDiscoverPanel.tsx +++ b/src/components/AdsDiscoverPanel.tsx @@ -23,9 +23,12 @@ type ProviderResult = { export default function AdsDiscoverPanel() { const [metaOk, setMetaOk] = useState(false); + const [metaPending, setMetaPending] = useState(true); const [gadsOk, setGadsOk] = useState(false); const [loading, setLoading] = useState(true); const [running, setRunning] = useState(false); + const [savingToken, setSavingToken] = useState(false); + const [metaToken, setMetaToken] = useState(''); const [meta, setMeta] = useState(null); const [google, setGoogle] = useState(null); const [msg, setMsg] = useState(null); @@ -36,6 +39,7 @@ export default function AdsDiscoverPanel() { const res = await fetch('/api/admin/ads/status'); const data = await res.json(); setMetaOk(Boolean(data.meta?.configured)); + setMetaPending(Boolean(data.meta?.pendingReview)); setGadsOk(Boolean(data.googleAds?.configured)); setLoading(false); } @@ -44,14 +48,33 @@ export default function AdsDiscoverPanel() { void loadStatus(); }, []); - async function discover() { + async function saveToken() { + setSavingToken(true); + setErr(null); + const res = await fetch('/api/admin/ads/meta-token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: metaToken }), + }); + const data = await res.json(); + if (!res.ok) { + setErr(data.error || 'No se pudo guardar el token'); + } else { + setMsg(`Token Meta guardado${data.name ? ` · ${data.name}` : ''}`); + setMetaToken(''); + await loadStatus(); + } + setSavingToken(false); + } + + async function discover(providers?: Array<'meta_ads' | 'google_ads'>) { setRunning(true); setErr(null); setMsg(null); const res = await fetch('/api/admin/ads/discover', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ apply: true }), + body: JSON.stringify({ apply: true, providers }), }); const data = await res.json(); if (!res.ok) { @@ -90,7 +113,7 @@ export default function AdsDiscoverPanel() { assign: { provider, tenantId, accountId, accountName }, }), }); - await discover(); + await discover([provider]); } if (loading) { @@ -120,40 +143,82 @@ export default function AdsDiscoverPanel() { Meta Ads + Google Ads (agencia)

- Lista las cuentas accesibles con marketing@ / token de agencia y - las enlaza a cada cliente por nombre. + Lista cuentas accesibles y las enlaza a cada cliente por nombre. + Google Ads ya puede emparejarse; Meta cuando tengas el token de + App Review.

- +
+ + +
- - Meta {metaOk ? 'listo' : 'falta META_ACCESS_TOKEN'} + + Meta {metaOk ? 'listo' : 'pendiente App Review / token'} - - Google Ads{' '} - {gadsOk ? 'listo' : 'falta GOOGLE_ADS_DEVELOPER_TOKEN + refresh'} + + Google Ads {gadsOk ? 'listo' : 'falta GOOGLE_ADS_*'}
+ {metaPending && ( +
+

+ Cuando Meta apruebe la solicitud, genera el token del usuario del + sistema y pégalo aquí (se guarda sin redeploy). +

+
+ setMetaToken(e.target.value)} + /> + +
+
+ )} + {msg &&

{msg}

} {err &&

{err}

} diff --git a/src/lib/ads-discovery.ts b/src/lib/ads-discovery.ts index 80386ea..25e415a 100644 --- a/src/lib/ads-discovery.ts +++ b/src/lib/ads-discovery.ts @@ -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 { + 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 { + 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 { + 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 { } export async function listMetaAdAccounts(): Promise { - 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();