feat: al conectar Google, auto-emparejar Drive/GA4/GSC/Ads en fichas

Bootstrap unificado post-OAuth + botón Emparejar todo. Meta discover
también intenta FB/IG pages con el token system user (API 4V2030).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 12:41:51 +02:00
parent 99895fe918
commit 7ed6e641cb
8 changed files with 374 additions and 75 deletions

View File

@@ -1,16 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import { isAgencyRole, userRole } from '@/lib/agency';
import {
applyMatches,
exchangeCode,
fetchGoogleEmail,
listDriveFolders,
matchFoldersToTenants,
saveAgencyOAuth,
} from '@/lib/google-drive';
import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
export const dynamic = 'force-dynamic';
export const maxDuration = 120;
function appUrl(): string {
return (
@@ -19,7 +18,7 @@ function appUrl(): string {
);
}
/** GET — OAuth callback: save tokens + auto-match folders → tenants. */
/** GET — OAuth callback: save tokens + auto-emparejar Drive/GA4/GSC/Ads → fichas. */
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
@@ -56,35 +55,27 @@ export async function GET(req: NextRequest) {
email,
});
// Auto-match folders (non-fatal: tokens already saved)
let linked = 0;
let ambiguous = 0;
let bootstrapMsg = '';
try {
const headers = serviceHeaders();
if (headers) {
const tRes = await fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
);
if (tRes.ok) {
const tenants = (await tRes.json()) as {
id: string;
name: string;
slug: string;
}[];
const folders = await listDriveFolders();
const matches = await matchFoldersToTenants(tenants, folders);
const applied = await applyMatches(matches);
linked = applied.linked;
ambiguous = matches.filter((m) => m.status === 'ambiguous').length;
}
}
} catch {
// match can fail without undoing OAuth
const boot = await bootstrapAgencyGoogle({
includeGoogleAds: true,
syncOrganic: true,
organicDays: 28,
});
linked = boot.drive?.linked || 0;
ambiguous = boot.drive?.ambiguous || 0;
bootstrapMsg = boot.message;
} catch (e) {
bootstrapMsg = e instanceof Error ? e.message : 'bootstrap failed';
}
const res = NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=ok&linked=${linked}&ambiguous=${ambiguous}`
`${appUrl()}/dashboard/agencia?drive=ok&linked=${linked}&ambiguous=${ambiguous}` +
(bootstrapMsg
? `&boot=${encodeURIComponent(bootstrapMsg.slice(0, 160))}`
: '')
);
res.cookies.set('4v_drive_oauth_state', '', { maxAge: 0, path: '/' });
return res;

View File

@@ -7,6 +7,7 @@ import {
userRole,
} from '@/lib/agency';
import { getAgencyOAuthStatus } from '@/lib/google-drive';
import { bootstrapAgencyGoogle } from '@/lib/agency-google-bootstrap';
import {
applyOrganicMatches,
listGa4Properties,
@@ -90,7 +91,8 @@ export async function GET() {
/**
* POST
* - { action: 'match', apply?: boolean } → match + optional write integrations
* - { action: 'bootstrap' } → Drive + GA4 + GSC + Google Ads → fichas + sync
* - { action: 'match', apply? } → solo GA4/GSC
* - { action: 'sync', tenantId?, days? } → sync organic_daily
*/
export async function POST(req: NextRequest) {
@@ -101,17 +103,38 @@ export async function POST(req: NextRequest) {
};
const body = await req.json().catch(() => ({}));
const action = body.action === 'sync' ? 'sync' : 'match';
const action =
body.action === 'sync'
? 'sync'
: body.action === 'bootstrap'
? 'bootstrap'
: 'match';
if (action === 'bootstrap') {
try {
const result = await bootstrapAgencyGoogle({
includeGoogleAds: body.includeGoogleAds !== false,
syncOrganic: body.syncOrganic !== false,
organicDays: Number(body.days) || 28,
});
return NextResponse.json(result, { status: result.ok ? 200 : 422 });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'bootstrap error' },
{ status: 500 }
);
}
}
if (action === 'match') {
try {
const [ga4, gsc, tRes] = await Promise.all([
listGa4Properties(),
listGscSites(),
fetch(`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`, {
headers,
cache: 'no-store',
}),
fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
),
]);
if (!tRes.ok) {
return NextResponse.json(
@@ -155,7 +178,6 @@ export async function POST(req: NextRequest) {
}
}
// sync — rolling window; default 28 on first fill / manual force
const days = Math.min(90, Math.max(3, Number(body.days) || 28));
const tenantFilter =
typeof body.tenantId === 'string' ? body.tenantId.trim() : '';