feat: Google Drive agency sync with auto folder match

Connect marketing@ once, match Drive folders to tenants by name,
list Drive in Activos, and mirror portal uploads to the linked folder.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-22 16:27:54 +02:00
parent 31ca77558e
commit 1eb02dac4d
17 changed files with 1629 additions and 173 deletions

View File

@@ -54,6 +54,7 @@ export async function POST(req: NextRequest) {
['clientify', 'Clientify'],
['klaviyo', 'Klaviyo'],
['mailchimp', 'Mailchimp'],
['google_drive', 'Google Drive'],
];
await fetch(`${url}/rest/v1/integrations`, {
method: 'POST',

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import {
applyMatches,
exchangeCode,
fetchGoogleEmail,
listDriveFolders,
matchFoldersToTenants,
saveAgencyOAuth,
} from '@/lib/google-drive';
export const dynamic = 'force-dynamic';
function appUrl(): string {
return (
process.env.NEXT_PUBLIC_APP_URL?.trim() ||
'https://app.4ventures.es'
);
}
/** GET — OAuth callback: save tokens + auto-match folders → tenants. */
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.redirect(`${appUrl()}/login`);
}
const code = req.nextUrl.searchParams.get('code');
const state = req.nextUrl.searchParams.get('state');
const err = req.nextUrl.searchParams.get('error');
const cookieState = req.cookies.get('4v_drive_oauth_state')?.value;
if (err) {
return NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=error&msg=${encodeURIComponent(err)}`
);
}
if (!code || !state || !cookieState || state !== cookieState) {
return NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=error&msg=state`
);
}
try {
const tokens = await exchangeCode(code);
const email = await fetchGoogleEmail(tokens.access_token);
await saveAgencyOAuth({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiresIn: tokens.expires_in,
scopes: tokens.scope,
email,
});
// Auto-match
const headers = serviceHeaders();
let linked = 0;
let ambiguous = 0;
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;
}
}
const res = NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=ok&linked=${linked}&ambiguous=${ambiguous}`
);
res.cookies.set('4v_drive_oauth_state', '', { maxAge: 0, path: '/' });
return res;
} catch (e) {
const msg = e instanceof Error ? e.message : 'oauth_failed';
return NextResponse.redirect(
`${appUrl()}/dashboard/agencia?drive=error&msg=${encodeURIComponent(msg.slice(0, 120))}`
);
}
}

View File

@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import {
driveAuthUrl,
driveConfigured,
} from '@/lib/google-drive';
import { randomBytes } from 'crypto';
export const dynamic = 'force-dynamic';
/** GET — start Google OAuth for agency Drive (marketing@). */
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 });
}
if (!driveConfigured()) {
return NextResponse.json(
{
error:
'Faltan GOOGLE_OAUTH_CLIENT_ID y GOOGLE_OAUTH_CLIENT_SECRET en el entorno.',
},
{ status: 503 }
);
}
const state = randomBytes(24).toString('hex');
const res = NextResponse.redirect(driveAuthUrl(state));
res.cookies.set('4v_drive_oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 600,
});
return res;
}

View File

@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
DRIVE_PROVIDER,
applyMatches,
listDriveFolders,
matchFoldersToTenants,
} from '@/lib/google-drive';
export const dynamic = 'force-dynamic';
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 }) };
}
const headers = serviceHeaders();
if (!headers) {
return {
error: NextResponse.json({ error: 'no service role' }, { status: 503 }),
};
}
return { headers };
}
/** POST — re-run auto-match (or body.assign for manual pick). */
export async function POST(req: NextRequest) {
const gate = await requireAgency();
if ('error' in gate && gate.error) return gate.error;
const { headers } = gate as {
headers: { apikey: string; Authorization: string };
};
const body = await req.json().catch(() => ({}));
// Manual assign: { tenantId, folderId, folderName }
if (body.tenantId && body.folderId) {
const patch = {
status: 'connected',
external_account_id: String(body.folderId),
label: String(body.folderName || 'Google Drive'),
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
folder_name: body.folderName || null,
match_score: 100,
matched_at: new Date().toISOString(),
manual: true,
},
updated_at: new Date().toISOString(),
};
const url = `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(body.tenantId)}&provider=eq.${DRIVE_PROVIDER}`;
const r = await fetch(url, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify(patch),
});
if (!r.ok) {
return NextResponse.json({ error: await r.text() }, { status: 500 });
}
const rows = await r.json();
return NextResponse.json({ integration: rows[0] || rows });
}
try {
const tRes = await fetch(
`${supabaseUrl()}/rest/v1/tenants?select=id,name,slug&order=name.asc`,
{ headers, cache: 'no-store' }
);
if (!tRes.ok) {
return NextResponse.json({ error: await tRes.text() }, { status: 500 });
}
const tenants = (await tRes.json()) as {
id: string;
name: string;
slug: string;
}[];
const folders = await listDriveFolders();
const matches = await matchFoldersToTenants(tenants, folders);
const apply = body.apply !== false;
let linked = 0;
if (apply) {
linked = (await applyMatches(matches)).linked;
}
return NextResponse.json({
linked,
folders: folders.length,
matches,
});
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'match failed' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { driveConfigured, getAgencyOAuthStatus } from '@/lib/google-drive';
export const dynamic = 'force-dynamic';
/** GET — agency Drive connection status (no tokens). */
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 });
}
try {
const status = await getAgencyOAuthStatus();
return NextResponse.json({
...status,
configured: driveConfigured(),
});
} catch (e) {
return NextResponse.json({
connected: false,
configured: driveConfigured(),
warning: e instanceof Error ? e.message : 'error',
});
}
}