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

594
src/lib/google-drive.ts Normal file
View File

@@ -0,0 +1,594 @@
/** Google Drive (agency marketing@) — OAuth, folder match, list/upload. */
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
export const DRIVE_PROVIDER = 'google_drive';
export const DRIVE_SCOPES = [
'https://www.googleapis.com/auth/drive',
'openid',
'email',
'profile',
].join(' ');
export type DriveFolder = { id: string; name: string };
export type MatchResult = {
tenantId: string;
tenantName: string;
tenantSlug: string;
folderId: string | null;
folderName: string | null;
score: number;
status: 'matched' | 'ambiguous' | 'unmatched';
candidates?: { id: string; name: string; score: number }[];
};
function appUrl(): string {
return (
process.env.NEXT_PUBLIC_APP_URL?.trim() ||
process.env.APP_URL?.trim() ||
'https://app.4ventures.es'
);
}
export function driveConfigured(): boolean {
return Boolean(
process.env.GOOGLE_OAUTH_CLIENT_ID?.trim() &&
process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim()
);
}
export function driveRedirectUri(): string {
return (
process.env.GOOGLE_OAUTH_REDIRECT_URI?.trim() ||
`${appUrl()}/api/admin/drive/callback`
);
}
export function driveAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
redirect_uri: driveRedirectUri(),
response_type: 'code',
scope: DRIVE_SCOPES,
access_type: 'offline',
prompt: 'consent',
state,
});
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}
export async function exchangeCode(code: string): Promise<{
access_token: string;
refresh_token?: string;
expires_in: number;
scope?: string;
}> {
const body = new URLSearchParams({
code,
client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!.trim(),
redirect_uri: driveRedirectUri(),
grant_type: 'authorization_code',
});
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(`token exchange: ${(await r.text()).slice(0, 300)}`);
return r.json();
}
async function refreshAccessToken(refreshToken: string): Promise<{
access_token: string;
expires_in: number;
}> {
const body = new URLSearchParams({
refresh_token: refreshToken,
client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!.trim(),
client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!.trim(),
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(`token refresh: ${(await r.text()).slice(0, 300)}`);
return r.json();
}
export async function fetchGoogleEmail(accessToken: string): Promise<string | null> {
const r = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` },
cache: 'no-store',
});
if (!r.ok) return null;
const j = (await r.json()) as { email?: string };
return j.email || null;
}
export async function saveAgencyOAuth(opts: {
accessToken: string;
refreshToken?: string | null;
expiresIn: number;
scopes?: string;
email?: string | null;
}): Promise<void> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const expiresAt = new Date(Date.now() + opts.expiresIn * 1000).toISOString();
const row: Record<string, unknown> = {
provider: DRIVE_PROVIDER,
access_token: opts.accessToken,
expires_at: expiresAt,
scopes: opts.scopes || DRIVE_SCOPES,
account_email: opts.email || null,
updated_at: new Date().toISOString(),
};
if (opts.refreshToken) row.refresh_token = opts.refreshToken;
// Upsert: try PATCH then POST
const base = `${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}`;
const patch = await fetch(base, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(row),
});
if (patch.ok) return;
const ins = await fetch(`${supabaseUrl()}/rest/v1/agency_oauth`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates',
},
body: JSON.stringify(row),
});
if (!ins.ok) throw new Error(`save oauth: ${(await ins.text()).slice(0, 300)}`);
}
type OAuthRow = {
access_token: string;
refresh_token?: string | null;
expires_at?: string | null;
account_email?: string | null;
};
export async function getAgencyOAuthStatus(): Promise<{
connected: boolean;
email?: string | null;
expiresAt?: string | null;
configured: boolean;
}> {
const configured = driveConfigured();
const headers = serviceHeaders();
if (!headers) return { connected: false, configured };
const r = await fetch(
`${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=account_email,expires_at,refresh_token,access_token`,
{ headers, cache: 'no-store' }
);
if (!r.ok) {
return {
connected: false,
configured,
};
}
const rows = (await r.json()) as OAuthRow[];
const row = rows[0];
if (!row?.access_token && !row?.refresh_token) {
return { connected: false, configured };
}
return {
connected: true,
email: row.account_email,
expiresAt: row.expires_at,
configured,
};
}
/** Valid access token (refresh if needed). */
export async function getValidAccessToken(): Promise<string> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
if (!driveConfigured()) throw new Error('GOOGLE_OAUTH_CLIENT_ID/SECRET no configurados');
const r = await fetch(
`${supabaseUrl()}/rest/v1/agency_oauth?provider=eq.${DRIVE_PROVIDER}&select=*`,
{ headers, cache: 'no-store' }
);
if (!r.ok) throw new Error(`agency_oauth: ${(await r.text()).slice(0, 200)}`);
const rows = (await r.json()) as OAuthRow[];
const row = rows[0];
if (!row) throw new Error('Drive no conectado. Conecta marketing@ en Agencia.');
const expires = row.expires_at ? new Date(row.expires_at).getTime() : 0;
if (row.access_token && expires > Date.now() + 60_000) {
return row.access_token;
}
if (!row.refresh_token) {
if (row.access_token) return row.access_token;
throw new Error('Sin refresh_token; vuelve a conectar Drive');
}
const refreshed = await refreshAccessToken(row.refresh_token);
await saveAgencyOAuth({
accessToken: refreshed.access_token,
refreshToken: row.refresh_token,
expiresIn: refreshed.expires_in,
email: row.account_email,
});
return refreshed.access_token;
}
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 scoreFolderToTenant(
folderName: string,
tenantName: string,
tenantSlug: string
): number {
const f = normalizeName(folderName);
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;
// token overlap
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 async function listDriveFolders(): Promise<DriveFolder[]> {
const token = await getValidAccessToken();
const out: DriveFolder[] = [];
let pageToken: string | undefined;
do {
const params = new URLSearchParams({
q: "mimeType='application/vnd.google-apps.folder' and trashed=false",
fields: 'nextPageToken,files(id,name)',
pageSize: '1000',
corpora: 'allDrives',
includeItemsFromAllDrives: 'true',
supportsAllDrives: 'true',
});
if (pageToken) params.set('pageToken', pageToken);
const r = await fetch(
`https://www.googleapis.com/drive/v3/files?${params}`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) throw new Error(`Drive list: ${(await r.text()).slice(0, 300)}`);
const j = (await r.json()) as {
files?: DriveFolder[];
nextPageToken?: string;
};
out.push(...(j.files || []));
pageToken = j.nextPageToken;
} while (pageToken);
return out;
}
export async function matchFoldersToTenants(
tenants: { id: string; name: string; slug: string }[],
folders: DriveFolder[]
): Promise<MatchResult[]> {
const THRESHOLD = 55;
const AMBIGUOUS_GAP = 8;
type Scored = { folder: DriveFolder; tenantId: string; score: number };
const all: Scored[] = [];
for (const t of tenants) {
for (const f of folders) {
const score = scoreFolderToTenant(f.name, t.name, t.slug);
if (score >= THRESHOLD) {
all.push({ folder: f, tenantId: t.id, score });
}
}
}
all.sort((a, b) => b.score - a.score);
const usedFolders = new Set<string>();
const usedTenants = new Set<string>();
const assigned = new Map<string, { folder: DriveFolder; score: number }>();
for (const row of all) {
if (usedFolders.has(row.folder.id) || usedTenants.has(row.tenantId)) continue;
// check ambiguity: another folder for same tenant within gap
const rivals = all.filter(
(x) =>
x.tenantId === row.tenantId &&
x.folder.id !== row.folder.id &&
!usedFolders.has(x.folder.id) &&
row.score - x.score < AMBIGUOUS_GAP &&
x.score >= THRESHOLD
);
if (rivals.length) continue; // leave for ambiguous review
assigned.set(row.tenantId, { folder: row.folder, score: row.score });
usedFolders.add(row.folder.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,
folderId: hit.folder.id,
folderName: hit.folder.name,
score: hit.score,
status: 'matched' as const,
};
}
const cands = all
.filter((x) => x.tenantId === t.id)
.slice(0, 5)
.map((x) => ({ id: x.folder.id, name: x.folder.name, score: x.score }));
if (cands.length) {
return {
tenantId: t.id,
tenantName: t.name,
tenantSlug: t.slug,
folderId: null,
folderName: null,
score: cands[0].score,
status: 'ambiguous' as const,
candidates: cands,
};
}
return {
tenantId: t.id,
tenantName: t.name,
tenantSlug: t.slug,
folderId: null,
folderName: null,
score: 0,
status: 'unmatched' as const,
};
});
}
export async function applyMatches(
matches: MatchResult[]
): 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.folderId) continue;
const patch = {
status: 'connected',
external_account_id: m.folderId,
label: m.folderName || 'Google Drive',
last_sync_at: new Date().toISOString(),
error_message: null,
meta: {
folder_name: m.folderName,
match_score: m.score,
matched_at: new Date().toISOString(),
},
updated_at: new Date().toISOString(),
};
const url = `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(m.tenantId)}&provider=eq.${DRIVE_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: DRIVE_PROVIDER,
...patch,
}),
});
}
if (r.ok) linked += 1;
}
return { linked };
}
export async function getTenantDriveFolderId(
tenantId: string
): Promise<{ folderId: string; folderName?: string } | null> {
const headers = serviceHeaders();
if (!headers) return null;
const r = await fetch(
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${DRIVE_PROVIDER}&select=status,external_account_id,label,meta`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return null;
const rows = await r.json();
const row = rows[0];
if (!row || row.status !== 'connected' || !row.external_account_id) return null;
return {
folderId: row.external_account_id,
folderName: row.meta?.folder_name || row.label || undefined,
};
}
export async function listFilesInFolder(folderId: string): Promise<
{
id: string;
name: string;
mimeType?: string;
size?: string;
modifiedTime?: string;
webViewLink?: string;
}[]
> {
const token = await getValidAccessToken();
const out: {
id: string;
name: string;
mimeType?: string;
size?: string;
modifiedTime?: string;
webViewLink?: string;
}[] = [];
let pageToken: string | undefined;
do {
const params = new URLSearchParams({
q: `'${folderId}' in parents and trashed=false`,
fields:
'nextPageToken,files(id,name,mimeType,size,modifiedTime,webViewLink)',
pageSize: '200',
supportsAllDrives: 'true',
includeItemsFromAllDrives: 'true',
});
if (pageToken) params.set('pageToken', pageToken);
const r = await fetch(
`https://www.googleapis.com/drive/v3/files?${params}`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) throw new Error(`Drive children: ${(await r.text()).slice(0, 300)}`);
const j = await r.json();
out.push(...(j.files || []));
pageToken = j.nextPageToken;
} while (pageToken);
return out;
}
/** Find or create a child folder by name under parent. */
export async function ensureChildFolder(
parentId: string,
name: string
): Promise<string> {
const token = await getValidAccessToken();
const safe = name.replace(/'/g, "\\'");
const q = `name='${safe}' and mimeType='application/vnd.google-apps.folder' and '${parentId}' in parents and trashed=false`;
const params = new URLSearchParams({
q,
fields: 'files(id,name)',
pageSize: '1',
supportsAllDrives: 'true',
includeItemsFromAllDrives: 'true',
});
const find = await fetch(
`https://www.googleapis.com/drive/v3/files?${params}`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (find.ok) {
const j = await find.json();
if (j.files?.[0]?.id) return j.files[0].id;
}
const create = await fetch(
'https://www.googleapis.com/drive/v3/files?supportsAllDrives=true',
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
mimeType: 'application/vnd.google-apps.folder',
parents: [parentId],
}),
}
);
if (!create.ok) {
throw new Error(`create folder: ${(await create.text()).slice(0, 300)}`);
}
const created = await create.json();
return created.id as string;
}
export async function uploadToDrive(opts: {
parentFolderId: string;
fileName: string;
mimeType: string;
buffer: Buffer;
}): Promise<{ id: string; webViewLink?: string }> {
const token = await getValidAccessToken();
const metadata = {
name: opts.fileName,
parents: [opts.parentFolderId],
};
const boundary = `4v_${Date.now()}`;
const metaPart = JSON.stringify(metadata);
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metaPart}\r\n`
),
Buffer.from(
`--${boundary}\r\nContent-Type: ${opts.mimeType}\r\n\r\n`
),
opts.buffer,
Buffer.from(`\r\n--${boundary}--`),
]);
const r = await fetch(
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,webViewLink',
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': `multipart/related; boundary=${boundary}`,
},
body,
}
);
if (!r.ok) throw new Error(`Drive upload: ${(await r.text()).slice(0, 300)}`);
return r.json();
}
export async function getDriveDownloadUrl(fileId: string): Promise<string> {
const token = await getValidAccessToken();
// Prefer webContentLink via metadata; else use alt=media with token (short-lived via redirect proxy)
const r = await fetch(
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=webViewLink,webContentLink,mimeType&supportsAllDrives=true`,
{
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}
);
if (!r.ok) throw new Error(`Drive file: ${(await r.text()).slice(0, 200)}`);
const j = await r.json();
return j.webContentLink || j.webViewLink || `https://drive.google.com/file/d/${fileId}/view`;
}