/** Google Drive (agency marketing@) — OAuth, folder match, list/upload. */ import { serviceHeaders, supabaseUrl } from '@/lib/agency'; export const DRIVE_PROVIDER = 'google_drive'; /** Unified agency Google scopes: Drive + GA4 + Search Console + Ads. */ export const DRIVE_SCOPES = [ 'https://www.googleapis.com/auth/drive', 'https://www.googleapis.com/auth/analytics.readonly', 'https://www.googleapis.com/auth/webmasters.readonly', 'https://www.googleapis.com/auth/adwords', '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 { 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 { const headers = serviceHeaders(); if (!headers) throw new Error('no service role'); const expiresAt = new Date(Date.now() + opts.expiresIn * 1000).toISOString(); const row: Record = { 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; // PostgREST PATCH returns 204 even when 0 rows match — do not treat that as success. // Upsert on primary key `provider`. const upsert = await fetch( `${supabaseUrl()}/rest/v1/agency_oauth?on_conflict=provider`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'resolution=merge-duplicates,return=representation', }, body: JSON.stringify(row), } ); if (!upsert.ok) { throw new Error(`save oauth: ${(await upsert.text()).slice(0, 300)}`); } const saved = (await upsert.json()) as unknown[]; if (!Array.isArray(saved) || saved.length === 0) { throw new Error('save oauth: upsert no escribió filas'); } } 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; scopes?: string | null; hasAnalytics?: boolean; hasSearchConsole?: boolean; hasDrive?: boolean; hasAds?: 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,scopes`, { headers, cache: 'no-store' } ); if (!r.ok) { return { connected: false, configured, }; } const rows = (await r.json()) as (OAuthRow & { scopes?: string | null })[]; const row = rows[0]; if (!row?.access_token && !row?.refresh_token) { return { connected: false, configured }; } const scopes = row.scopes || ''; return { connected: true, email: row.account_email, expiresAt: row.expires_at, configured, scopes, hasAnalytics: scopes.includes('analytics'), hasSearchConsole: scopes.includes('webmasters') || scopes.includes('searchconsole'), hasDrive: scopes.includes('drive') || !scopes, hasAds: scopes.includes('adwords') || scopes.includes('https://www.googleapis.com/auth/adwords'), }; } /** Valid access token (refresh if needed). */ export async function getValidAccessToken(): Promise { 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 { // «27-Agua Miia» / «11-Blue Parrot School» → strip numeric prefix const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim(); const candidates = [folderName, stripped]; const aliasMap: Record = { bps: ['blue parrot', 'blue parrot school', 'blueparrotschool'], 'wine advisors': ['wine advisor', 'wineadvisor'], 'de tu a tu formacion': ['detuatu', 'de tu a tu'], 'clinica dental sonrisas': ['sonrisas', 'clinica sonrisas'], }; let best = 0; for (const raw of candidates) { const f = normalizeName(raw); const n = normalizeName(tenantName); const s = normalizeName(tenantSlug.replace(/-/g, ' ')); if (!f || (!n && !s)) continue; if (f === n || f === s) best = Math.max(best, 100); else if (n && (f.includes(n) || n.includes(f))) best = Math.max(best, 85); else if (s && (f.includes(s) || s.includes(f))) best = Math.max(best, 80); else { const aliases = aliasMap[n] || aliasMap[s] || []; for (const a of aliases) { if (f.includes(a) || a.includes(f)) best = Math.max(best, 90); } 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) { let hit = 0; for (const t of nt) if (ft.has(t)) hit += 1; const ratio = hit / nt.size; if (ratio >= 0.8) best = Math.max(best, 70); else if (ratio >= 0.5) best = Math.max(best, 55); else best = Math.max(best, Math.round(ratio * 50)); } } } return best; } export async function listDriveFolders(): Promise { /** Prefer children of agency folder «Clientes»; fallback to all folders. */ const under = await listClientFoldersUnderClientes(); if (under.folders.length) return under.folders; return listAllDriveFolders(); } /** All folders visible to marketing@ (legacy / fallback). */ export async function listAllDriveFolders(): Promise { 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; } /** Find the agency «Clientes» root (prefer «03-Clientes»). */ export async function findClientesRootFolder(): Promise { const token = await getValidAccessToken(); const preferred = [ '03-Clientes', 'Clientes', '01.Clientes - Documentos maestros', ]; for (const name of preferred) { const safe = name.replace(/'/g, "\\'"); const params = new URLSearchParams({ q: `name='${safe}' and mimeType='application/vnd.google-apps.folder' and trashed=false`, fields: 'files(id,name)', pageSize: '5', corpora: 'allDrives', includeItemsFromAllDrives: 'true', supportsAllDrives: 'true', }); const r = await fetch( `https://www.googleapis.com/drive/v3/files?${params}`, { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store', } ); if (!r.ok) continue; const j = (await r.json()) as { files?: DriveFolder[] }; const hit = (j.files || []).find((f) => f.name === name); if (hit) return hit; } // Fallback: any folder whose name contains «Clientes» with many children later const params = new URLSearchParams({ q: "name contains 'Clientes' and mimeType='application/vnd.google-apps.folder' and trashed=false", fields: 'files(id,name)', pageSize: '20', corpora: 'allDrives', includeItemsFromAllDrives: 'true', supportsAllDrives: 'true', }); const r = await fetch( `https://www.googleapis.com/drive/v3/files?${params}`, { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store', } ); if (!r.ok) return null; const j = (await r.json()) as { files?: DriveFolder[] }; const files = (j.files || []).filter( (f) => /clientes/i.test(f.name) && !/opensalud|documentos maestros/i.test(f.name) ); // Prefer 03-Clientes style return ( files.find((f) => /^0?\d+[-.]?\s*clientes$/i.test(f.name)) || files.find((f) => /clientes/i.test(f.name)) || null ); } /** Direct child folders of «Clientes» — the only valid tenant roots. */ export async function listClientFoldersUnderClientes(): Promise<{ clientesRoot: DriveFolder | null; folders: DriveFolder[]; }> { const root = await findClientesRootFolder(); if (!root) return { clientesRoot: null, folders: [] }; const children = await listFilesInFolder(root.id); const folders = children .filter((f) => f.mimeType === 'application/vnd.google-apps.folder') .map((f) => ({ id: f.id, name: f.name })); return { clientesRoot: root, folders }; } /** Walk parents to check fileId is rootId or a descendant. */ export async function isUnderDriveFolder( fileId: string, rootId: string, maxHops = 24 ): Promise { if (!fileId || !rootId) return false; if (fileId === rootId) return true; const token = await getValidAccessToken(); let cur = fileId; for (let i = 0; i < maxHops; i++) { const r = await fetch( `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(cur)}?fields=id,parents&supportsAllDrives=true`, { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store', } ); if (!r.ok) return false; const j = (await r.json()) as { parents?: string[] }; const parents = j.parents || []; if (!parents.length) return false; if (parents.includes(rootId)) return true; cur = parents[0]; } return false; } export async function getDriveFileMeta( fileId: string ): Promise<{ id: string; name: string; parents?: string[] } | null> { const token = await getValidAccessToken(); const r = await fetch( `https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}?fields=id,name,parents&supportsAllDrives=true`, { headers: { Authorization: `Bearer ${token}` }, cache: 'no-store', } ); if (!r.ok) return null; return r.json(); } export async function matchFoldersToTenants( tenants: { id: string; name: string; slug: string }[], folders: DriveFolder[] ): Promise { 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(); const usedTenants = new Set(); const assigned = new Map(); 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 { 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 { 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`; }