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:
11
.env.example
11
.env.example
@@ -15,3 +15,14 @@ GOOGLE_OAUTH_CLIENT_ID=
|
|||||||
GOOGLE_OAUTH_CLIENT_SECRET=
|
GOOGLE_OAUTH_CLIENT_SECRET=
|
||||||
# Optional override; default: {APP_URL}/api/admin/drive/callback
|
# Optional override; default: {APP_URL}/api/admin/drive/callback
|
||||||
# GOOGLE_OAUTH_REDIRECT_URI=https://app.4ventures.es/api/admin/drive/callback
|
# GOOGLE_OAUTH_REDIRECT_URI=https://app.4ventures.es/api/admin/drive/callback
|
||||||
|
|
||||||
|
# Meta Ads discovery (long-lived / system user token)
|
||||||
|
META_ACCESS_TOKEN=
|
||||||
|
|
||||||
|
# Google Ads discovery (MCC via marketing@)
|
||||||
|
GOOGLE_ADS_DEVELOPER_TOKEN=
|
||||||
|
GOOGLE_ADS_REFRESH_TOKEN=
|
||||||
|
# Optional: reuse GOOGLE_OAUTH_CLIENT_* or set dedicated:
|
||||||
|
# GOOGLE_ADS_CLIENT_ID=
|
||||||
|
# GOOGLE_ADS_CLIENT_SECRET=
|
||||||
|
# GOOGLE_ADS_LOGIN_CUSTOMER_ID=
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
- Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py`
|
- Biblioteca de activos (archivos): `sql/asset_files.sql` + bucket privado `tenant-assets` (máx. 80 MB/archivo). UI: `/dashboard/activos`. Apply: `python3 scripts/dokploy_apply_asset_files.py`
|
||||||
- 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.
|
||||||
|
|
||||||
## Login
|
## Login
|
||||||
|
|
||||||
|
|||||||
76
src/app/api/admin/ads/discover/route.ts
Normal file
76
src/app/api/admin/ads/discover/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import {
|
||||||
|
isAgencyRole,
|
||||||
|
serviceHeaders,
|
||||||
|
userRole,
|
||||||
|
} from '@/lib/agency';
|
||||||
|
import { discoverAndMatch, applyProviderMatches } from '@/lib/ads-discovery';
|
||||||
|
import type { AccountMatch } from '@/lib/match';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
async function requireAgency() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user || !isAgencyRole(userRole(user))) {
|
||||||
|
return { error: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
if (!serviceHeaders()) {
|
||||||
|
return {
|
||||||
|
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { user };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST — discover Meta/Google accounts and auto-match to tenants.
|
||||||
|
* Body: { apply?: boolean, providers?: ['meta_ads'|'google_ads'],
|
||||||
|
* assign?: { provider, tenantId, accountId, accountName } }
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const gate = await requireAgency();
|
||||||
|
if ('error' in gate && gate.error) return gate.error;
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (body.assign?.tenantId && body.assign?.accountId && body.assign?.provider) {
|
||||||
|
const provider = body.assign.provider as 'meta_ads' | 'google_ads';
|
||||||
|
if (provider !== 'meta_ads' && provider !== 'google_ads') {
|
||||||
|
return NextResponse.json({ error: 'provider inválido' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const fake: AccountMatch[] = [
|
||||||
|
{
|
||||||
|
tenantId: body.assign.tenantId,
|
||||||
|
tenantName: '',
|
||||||
|
tenantSlug: '',
|
||||||
|
accountId: String(body.assign.accountId),
|
||||||
|
accountName: String(body.assign.accountName || body.assign.accountId),
|
||||||
|
score: 100,
|
||||||
|
status: 'matched',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { linked } = await applyProviderMatches(
|
||||||
|
provider,
|
||||||
|
fake,
|
||||||
|
provider === 'meta_ads' ? 'Meta Ads' : 'Google Ads'
|
||||||
|
);
|
||||||
|
return NextResponse.json({ linked, manual: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await discoverAndMatch({
|
||||||
|
apply: body.apply !== false,
|
||||||
|
providers: body.providers,
|
||||||
|
});
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: e instanceof Error ? e.message : 'discover failed' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/app/api/admin/ads/status/route.ts
Normal file
25
src/app/api/admin/ads/status/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { isAgencyRole, userRole } from '@/lib/agency';
|
||||||
|
import {
|
||||||
|
googleAdsConfigured,
|
||||||
|
metaConfigured,
|
||||||
|
} from '@/lib/ads-discovery';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** GET — whether Meta / Google Ads discovery credentials are ready. */
|
||||||
|
export async function GET() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user || !isAgencyRole(userRole(user))) {
|
||||||
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
meta: { configured: metaConfigured() },
|
||||||
|
googleAds: { configured: googleAdsConfigured() },
|
||||||
|
});
|
||||||
|
}
|
||||||
197
src/components/AdsDiscoverPanel.tsx
Normal file
197
src/components/AdsDiscoverPanel.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Megaphone, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
type MatchRow = {
|
||||||
|
tenantId: string;
|
||||||
|
tenantName: string;
|
||||||
|
tenantSlug: string;
|
||||||
|
accountId: string | null;
|
||||||
|
accountName: string | null;
|
||||||
|
score: number;
|
||||||
|
status: string;
|
||||||
|
candidates?: { id: string; name: string; score: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProviderResult = {
|
||||||
|
accounts: number;
|
||||||
|
linked: number;
|
||||||
|
matches: MatchRow[];
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdsDiscoverPanel() {
|
||||||
|
const [metaOk, setMetaOk] = useState(false);
|
||||||
|
const [gadsOk, setGadsOk] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [meta, setMeta] = useState<ProviderResult | null>(null);
|
||||||
|
const [google, setGoogle] = useState<ProviderResult | null>(null);
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/admin/ads/status');
|
||||||
|
const data = await res.json();
|
||||||
|
setMetaOk(Boolean(data.meta?.configured));
|
||||||
|
setGadsOk(Boolean(data.googleAds?.configured));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function discover() {
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
setErr(data.error || 'Discover falló');
|
||||||
|
} else {
|
||||||
|
setMeta(data.meta || null);
|
||||||
|
setGoogle(data.google || null);
|
||||||
|
const parts = [];
|
||||||
|
if (data.meta) {
|
||||||
|
parts.push(
|
||||||
|
`Meta: ${data.meta.linked}/${data.meta.accounts} enlazadas` +
|
||||||
|
(data.meta.error ? ` (${data.meta.error})` : '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.google) {
|
||||||
|
parts.push(
|
||||||
|
`Google Ads: ${data.google.linked}/${data.google.accounts} enlazadas` +
|
||||||
|
(data.google.error ? ` (${data.google.error})` : '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setMsg(parts.join(' · '));
|
||||||
|
}
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assign(
|
||||||
|
provider: 'meta_ads' | 'google_ads',
|
||||||
|
tenantId: string,
|
||||||
|
accountId: string,
|
||||||
|
accountName: string
|
||||||
|
) {
|
||||||
|
await fetch('/api/admin/ads/discover', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
assign: { provider, tenantId, accountId, accountName },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await discover();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" /> Ads…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ambiguous = [
|
||||||
|
...(meta?.matches || [])
|
||||||
|
.filter((m) => m.status === 'ambiguous')
|
||||||
|
.map((m) => ({ ...m, provider: 'meta_ads' as const })),
|
||||||
|
...(google?.matches || [])
|
||||||
|
.filter((m) => m.status === 'ambiguous')
|
||||||
|
.map((m) => ({ ...m, provider: 'google_ads' as const })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-gray-800 bg-[#111827] p-5 space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Megaphone className="w-5 h-5 text-brand-400 mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-white">
|
||||||
|
Meta Ads + Google Ads (agencia)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5 max-w-xl">
|
||||||
|
Lista las cuentas accesibles con marketing@ / token de agencia y
|
||||||
|
las enlaza a cada cliente por nombre.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void discover()}
|
||||||
|
disabled={running || (!metaOk && !gadsOk)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-brand-500/90 hover:bg-brand-500 text-white disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
Descubrir y emparejar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 text-[11px]">
|
||||||
|
<span
|
||||||
|
className={metaOk ? 'text-emerald-400' : 'text-amber-400'}
|
||||||
|
>
|
||||||
|
Meta {metaOk ? 'listo' : 'falta META_ACCESS_TOKEN'}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={gadsOk ? 'text-emerald-400' : 'text-amber-400'}
|
||||||
|
>
|
||||||
|
Google Ads{' '}
|
||||||
|
{gadsOk ? 'listo' : 'falta GOOGLE_ADS_DEVELOPER_TOKEN + refresh'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg && <p className="text-xs text-gray-300">{msg}</p>}
|
||||||
|
{err && <p className="text-xs text-red-400">{err}</p>}
|
||||||
|
|
||||||
|
{ambiguous.length > 0 && (
|
||||||
|
<ul className="space-y-2 max-h-56 overflow-y-auto">
|
||||||
|
{ambiguous.map((m) => (
|
||||||
|
<li
|
||||||
|
key={`${m.provider}-${m.tenantId}`}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center gap-2 text-xs border border-gray-800 rounded-lg px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium truncate">
|
||||||
|
{m.tenantName}{' '}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
· {m.provider === 'meta_ads' ? 'Meta' : 'GAds'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500">Ambiguo — elige cuenta</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(m.candidates || []).map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
void assign(m.provider, m.tenantId, c.id, c.name)
|
||||||
|
}
|
||||||
|
className="px-2 py-1 rounded border border-gray-700 text-gray-300 hover:text-white"
|
||||||
|
title={`score ${c.score}`}
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import DriveConnectPanel from '@/components/DriveConnectPanel';
|
import DriveConnectPanel from '@/components/DriveConnectPanel';
|
||||||
|
import AdsDiscoverPanel from '@/components/AdsDiscoverPanel';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Bell,
|
Bell,
|
||||||
@@ -179,6 +180,7 @@ export default function AgencyPortfolioBoard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<DriveConnectPanel />
|
<DriveConnectPanel />
|
||||||
|
<AdsDiscoverPanel />
|
||||||
|
|
||||||
{warning && (
|
{warning && (
|
||||||
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
<p className="text-xs text-amber-400/90 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||||
|
|||||||
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