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:
4Ventures
2026-09-22 19:24:11 +02:00
parent eb00256bea
commit c13001c69d
8 changed files with 798 additions and 0 deletions

View 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 }
);
}
}

View 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() },
});
}