Files
4ventures-client-portal/src/lib/google-drive.ts
4Ventures 331c19e873 fix: creatividades — Google siempre con fallback; Meta preview 9:16 y textos reales
OAuth Ads caducado: aviso amable + campañas warehouse. Meta deja de volcar todas las variantes del asset_feed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-28 18:46:24 +02:00

756 lines
23 KiB
TypeScript

/** 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<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;
// 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<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 {
// «27-Agua Miia» / «11-Blue Parrot School» → strip numeric prefix
const stripped = folderName.replace(/^\d+[\.\-\s_]+/, '').trim();
const candidates = [folderName, stripped];
const aliasMap: Record<string, string[]> = {
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<DriveFolder[]> {
/** 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<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;
}
/** Find the agency «Clientes» root (prefer «03-Clientes»). */
export async function findClientesRootFolder(): Promise<DriveFolder | null> {
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<boolean> {
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<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`;
}