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:
@@ -9,6 +9,7 @@ export const INTEGRATION_PROVIDERS = [
|
||||
{ id: 'clientify', label: 'Clientify', group: 'crm' },
|
||||
{ id: 'klaviyo', label: 'Klaviyo', group: 'email' },
|
||||
{ id: 'mailchimp', label: 'Mailchimp', group: 'email' },
|
||||
{ id: 'google_drive', label: 'Google Drive', group: 'files' },
|
||||
] as const;
|
||||
|
||||
export type IntegrationProviderId =
|
||||
|
||||
594
src/lib/google-drive.ts
Normal file
594
src/lib/google-drive.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user