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:
@@ -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.
|
- 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.
|
- 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/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
|
## Login
|
||||||
|
|
||||||
|
|||||||
25
src/app/api/admin/ads/meta-token/route.ts
Normal file
25
src/app/api/admin/ads/meta-token/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { isAgencyRole, userRole } from '@/lib/agency';
|
|||||||
import {
|
import {
|
||||||
googleAdsConfigured,
|
googleAdsConfigured,
|
||||||
metaConfigured,
|
metaConfigured,
|
||||||
|
metaEnvConfigured,
|
||||||
} from '@/lib/ads-discovery';
|
} from '@/lib/ads-discovery';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -18,8 +19,13 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metaReady = await metaConfigured();
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
meta: { configured: metaConfigured() },
|
meta: {
|
||||||
|
configured: metaReady,
|
||||||
|
envFallback: metaEnvConfigured(),
|
||||||
|
pendingReview: !metaReady,
|
||||||
|
},
|
||||||
googleAds: { configured: googleAdsConfigured() },
|
googleAds: { configured: googleAdsConfigured() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ type ProviderResult = {
|
|||||||
|
|
||||||
export default function AdsDiscoverPanel() {
|
export default function AdsDiscoverPanel() {
|
||||||
const [metaOk, setMetaOk] = useState(false);
|
const [metaOk, setMetaOk] = useState(false);
|
||||||
|
const [metaPending, setMetaPending] = useState(true);
|
||||||
const [gadsOk, setGadsOk] = useState(false);
|
const [gadsOk, setGadsOk] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
|
const [savingToken, setSavingToken] = useState(false);
|
||||||
|
const [metaToken, setMetaToken] = useState('');
|
||||||
const [meta, setMeta] = useState<ProviderResult | null>(null);
|
const [meta, setMeta] = useState<ProviderResult | null>(null);
|
||||||
const [google, setGoogle] = useState<ProviderResult | null>(null);
|
const [google, setGoogle] = useState<ProviderResult | null>(null);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
@@ -36,6 +39,7 @@ export default function AdsDiscoverPanel() {
|
|||||||
const res = await fetch('/api/admin/ads/status');
|
const res = await fetch('/api/admin/ads/status');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setMetaOk(Boolean(data.meta?.configured));
|
setMetaOk(Boolean(data.meta?.configured));
|
||||||
|
setMetaPending(Boolean(data.meta?.pendingReview));
|
||||||
setGadsOk(Boolean(data.googleAds?.configured));
|
setGadsOk(Boolean(data.googleAds?.configured));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -44,14 +48,33 @@ export default function AdsDiscoverPanel() {
|
|||||||
void loadStatus();
|
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);
|
setRunning(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
const res = await fetch('/api/admin/ads/discover', {
|
const res = await fetch('/api/admin/ads/discover', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ apply: true }),
|
body: JSON.stringify({ apply: true, providers }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -90,7 +113,7 @@ export default function AdsDiscoverPanel() {
|
|||||||
assign: { provider, tenantId, accountId, accountName },
|
assign: { provider, tenantId, accountId, accountName },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
await discover();
|
await discover([provider]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -120,11 +143,26 @@ export default function AdsDiscoverPanel() {
|
|||||||
Meta Ads + Google Ads (agencia)
|
Meta Ads + Google Ads (agencia)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
||||||
Lista las cuentas accesibles con marketing@ / token de agencia y
|
Lista cuentas accesibles y las enlaza a cada cliente por nombre.
|
||||||
las enlaza a cada cliente por nombre.
|
Google Ads ya puede emparejarse; Meta cuando tengas el token de
|
||||||
|
App Review.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void discover(['google_ads'])}
|
||||||
|
disabled={running || !gadsOk}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border border-gray-700 text-gray-200 hover:border-gray-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Solo Google Ads
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void discover()}
|
onClick={() => void discover()}
|
||||||
@@ -136,24 +174,51 @@ export default function AdsDiscoverPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
Descubrir y emparejar
|
Descubrir ambos
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3 text-[11px]">
|
<div className="flex flex-wrap gap-3 text-[11px]">
|
||||||
<span
|
<span className={metaOk ? 'text-emerald-400' : 'text-amber-400'}>
|
||||||
className={metaOk ? 'text-emerald-400' : 'text-amber-400'}
|
Meta {metaOk ? 'listo' : 'pendiente App Review / token'}
|
||||||
>
|
|
||||||
Meta {metaOk ? 'listo' : 'falta META_ACCESS_TOKEN'}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className={gadsOk ? 'text-emerald-400' : 'text-amber-400'}>
|
||||||
className={gadsOk ? 'text-emerald-400' : 'text-amber-400'}
|
Google Ads {gadsOk ? 'listo' : 'falta GOOGLE_ADS_*'}
|
||||||
>
|
|
||||||
Google Ads{' '}
|
|
||||||
{gadsOk ? 'listo' : 'falta GOOGLE_ADS_DEVELOPER_TOKEN + refresh'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{metaPending && (
|
||||||
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-3 space-y-2">
|
||||||
|
<p className="text-xs text-amber-200/90">
|
||||||
|
Cuando Meta apruebe la solicitud, genera el token del usuario del
|
||||||
|
sistema y pégalo aquí (se guarda sin redeploy).
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
className="flex-1 text-xs rounded-lg bg-gray-900 border border-gray-700 px-3 py-2 text-white placeholder:text-gray-600"
|
||||||
|
placeholder="EAAG… system user token"
|
||||||
|
value={metaToken}
|
||||||
|
onChange={(e) => setMetaToken(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={savingToken || metaToken.trim().length < 20}
|
||||||
|
onClick={() => void saveToken()}
|
||||||
|
className="text-xs px-3 py-2 rounded-lg border border-amber-500/40 text-amber-200 hover:bg-amber-500/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{savingToken ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin inline" />
|
||||||
|
) : (
|
||||||
|
'Guardar token Meta'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{msg && <p className="text-xs text-gray-300">{msg}</p>}
|
{msg && <p className="text-xs text-gray-300">{msg}</p>}
|
||||||
{err && <p className="text-xs text-red-400">{err}</p>}
|
{err && <p className="text-xs text-red-400">{err}</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {
|
|||||||
|
|
||||||
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
const META_GRAPH = 'https://graph.facebook.com/v21.0';
|
||||||
const GADS_API = 'https://googleads.googleapis.com/v18';
|
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());
|
return Boolean(process.env.META_ACCESS_TOKEN?.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +31,90 @@ export function googleAdsConfigured(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaToken(): string {
|
/** Prefer token saved in agency_oauth (pasted after App Review), else env. */
|
||||||
const t = process.env.META_ACCESS_TOKEN?.trim();
|
export async function resolveMetaAccessToken(): Promise<string | null> {
|
||||||
if (!t) throw new Error('Falta META_ACCESS_TOKEN');
|
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;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +152,7 @@ async function googleAdsAccessToken(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listMetaAdAccounts(): Promise<NamedAccount[]> {
|
export async function listMetaAdAccounts(): Promise<NamedAccount[]> {
|
||||||
const token = metaToken();
|
const token = await metaToken();
|
||||||
const out: NamedAccount[] = [];
|
const out: NamedAccount[] = [];
|
||||||
let url: string | null =
|
let url: string | null =
|
||||||
`${META_GRAPH}/me/adaccounts?fields=id,account_id,name,account_status&limit=200&access_token=${encodeURIComponent(token)}`;
|
`${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')) {
|
if (want.includes('meta_ads')) {
|
||||||
try {
|
try {
|
||||||
if (!metaConfigured()) {
|
if (!(await metaConfigured())) {
|
||||||
result.meta = {
|
result.meta = {
|
||||||
accounts: 0,
|
accounts: 0,
|
||||||
linked: 0,
|
linked: 0,
|
||||||
matches: [],
|
matches: [],
|
||||||
error: 'META_ACCESS_TOKEN no configurado',
|
error:
|
||||||
|
'Meta pendiente: pega el system user token en Agencia cuando App Review lo apruebe',
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const accounts = await listMetaAdAccounts();
|
const accounts = await listMetaAdAccounts();
|
||||||
|
|||||||
Reference in New Issue
Block a user