From 5c9b2d8d6b11cff20fa64246c09e15a991b39053 Mon Sep 17 00:00:00 2001 From: waleed Date: Tue, 9 Jun 2026 20:34:28 -0700 Subject: [PATCH] feat(files): export workspace files to Google Drive Add an Export action to the Files module that pushes selected workspace files straight to a user's Google Drive, reusing the same OAuth credentials the Google Drive block uses. The existing Download action becomes a dropdown (Download / Google Drive) in the row context menu, bulk action bar, and file-viewer menu. - Shared uploadBufferToDrive() helper; refactor the Drive tool upload route to use it (single upload path) - Contract-bound POST /api/workspaces/[id]/files/export-to-drive with per-file error reporting; tokens via refreshAccessTokenIfNeeded - ExportToDriveModal: account picker + inline connect via a dedicated 'files' OAuth return origin - Route tests cover auth, validation, token, no-files, success, partial --- .../api/tools/google_drive/upload/route.ts | 130 ++---------- .../[id]/files/export-to-drive/route.test.ts | 158 +++++++++++++++ .../[id]/files/export-to-drive/route.ts | 108 ++++++++++ .../connect-oauth-modal.tsx | 3 + .../components/action-bar/action-bar.tsx | 61 ++++-- .../export-to-drive-modal.tsx | 188 ++++++++++++++++++ .../components/export-to-drive-modal/index.ts | 1 + .../file-row-context-menu.tsx | 33 ++- .../workspace/[workspaceId]/files/files.tsx | 87 ++++++++ .../[workspaceId]/files/pending-export.ts | 50 +++++ apps/sim/hooks/queries/workspace-files.ts | 26 +++ apps/sim/hooks/use-oauth-return.ts | 29 +++ apps/sim/lib/api/contracts/workspace-files.ts | 51 +++++ apps/sim/lib/credentials/client-state.ts | 7 +- .../lib/google-drive/upload-to-drive.test.ts | 72 +++++++ apps/sim/lib/google-drive/upload-to-drive.ts | 163 +++++++++++++++ 16 files changed, 1033 insertions(+), 134 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/files/export-to-drive/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/export-to-drive/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/export-to-drive-modal/export-to-drive-modal.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/export-to-drive-modal/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/files/pending-export.ts create mode 100644 apps/sim/lib/google-drive/upload-to-drive.test.ts create mode 100644 apps/sim/lib/google-drive/upload-to-drive.ts diff --git a/apps/sim/app/api/tools/google_drive/upload/route.ts b/apps/sim/app/api/tools/google_drive/upload/route.ts index 9c0cd1ccd9f..828b5abb4fc 100644 --- a/apps/sim/app/api/tools/google_drive/upload/route.ts +++ b/apps/sim/app/api/tools/google_drive/upload/route.ts @@ -1,12 +1,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { googleDriveUploadContract } from '@/lib/api/contracts/tools/google' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { DriveUploadError, uploadBufferToDrive } from '@/lib/google-drive/upload-to-drive' import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { assertToolFileAccess } from '@/app/api/files/authorization' @@ -20,35 +20,6 @@ export const dynamic = 'force-dynamic' const logger = createLogger('GoogleDriveUploadAPI') -const GOOGLE_DRIVE_API_BASE = 'https://www.googleapis.com/upload/drive/v3/files' - -/** - * Build multipart upload body for Google Drive API - */ -function buildMultipartBody( - metadata: Record, - fileBuffer: Buffer, - mimeType: string, - boundary: string -): string { - const parts: string[] = [] - - parts.push(`--${boundary}`) - parts.push('Content-Type: application/json; charset=UTF-8') - parts.push('') - parts.push(JSON.stringify(metadata)) - - parts.push(`--${boundary}`) - parts.push(`Content-Type: ${mimeType}`) - parts.push('Content-Transfer-Encoding: base64') - parts.push('') - parts.push(fileBuffer.toString('base64')) - - parts.push(`--${boundary}--`) - - return parts.join('\r\n') -} - export const POST = withRouteHandler(async (request: NextRequest) => { const requestId = generateRequestId() @@ -159,23 +130,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } } - const metadata: { - name: string - mimeType: string - parents?: string[] - } = { - name: validatedData.fileName, - mimeType: requestedMimeType, - } - - if (validatedData.folderId && validatedData.folderId.trim() !== '') { - metadata.parents = [validatedData.folderId.trim()] - } - - const boundary = `boundary_${Date.now()}_${generateShortId(7)}` - - const multipartBody = buildMultipartBody(metadata, fileBuffer, uploadMimeType, boundary) - logger.info(`[${requestId}] Uploading to Google Drive via multipart upload`, { fileName: validatedData.fileName, size: fileBuffer.length, @@ -183,75 +137,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { requestedMimeType, }) - const uploadResponse = await fetch( - `${GOOGLE_DRIVE_API_BASE}?uploadType=multipart&supportsAllDrives=true`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': `multipart/related; boundary=${boundary}`, - 'Content-Length': Buffer.byteLength(multipartBody, 'utf-8').toString(), - }, - body: multipartBody, - } - ) - - if (!uploadResponse.ok) { - const errorText = await uploadResponse.text() - logger.error(`[${requestId}] Google Drive API error:`, { - status: uploadResponse.status, - statusText: uploadResponse.statusText, - error: errorText, + let finalFile + try { + finalFile = await uploadBufferToDrive({ + accessToken: validatedData.accessToken, + name: validatedData.fileName, + mimeType: requestedMimeType, + uploadMimeType, + buffer: fileBuffer, + folderId: validatedData.folderId ?? undefined, }) - return NextResponse.json( - { - success: false, - error: `Google Drive API error: ${uploadResponse.statusText}`, - }, - { status: uploadResponse.status } - ) - } - - const uploadData = await uploadResponse.json() - const fileId = uploadData.id - - logger.info(`[${requestId}] File uploaded successfully`, { fileId }) - - if (GOOGLE_WORKSPACE_MIME_TYPES.includes(requestedMimeType)) { - logger.info(`[${requestId}] Updating file name to ensure it persists after conversion`) - - const updateNameResponse = await fetch( - `https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: validatedData.fileName, - }), - } - ) - - if (!updateNameResponse.ok) { - logger.warn( - `[${requestId}] Failed to update filename after conversion, but content was uploaded` - ) + } catch (error) { + if (error instanceof DriveUploadError) { + logger.error(`[${requestId}] Google Drive API error:`, { + status: error.status, + error: error.message, + }) + return NextResponse.json({ success: false, error: error.message }, { status: error.status }) } + throw error } - const finalFileResponse = await fetch( - `https://www.googleapis.com/drive/v3/files/${fileId}?supportsAllDrives=true&fields=id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents`, - { - headers: { - Authorization: `Bearer ${validatedData.accessToken}`, - }, - } - ) - - const finalFile = await finalFileResponse.json() - logger.info(`[${requestId}] Upload complete`, { fileId: finalFile.id, fileName: finalFile.name, diff --git a/apps/sim/app/api/workspaces/[id]/files/export-to-drive/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/export-to-drive/route.test.ts new file mode 100644 index 00000000000..10d3d7bfcec --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/export-to-drive/route.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockListWorkspaceFiles, + mockFetchWorkspaceFileBuffer, + mockRefreshAccessTokenIfNeeded, + mockUploadBufferToDrive, + mockVerifyWorkspaceMembership, +} = vi.hoisted(() => ({ + mockListWorkspaceFiles: vi.fn(), + mockFetchWorkspaceFileBuffer: vi.fn(), + mockRefreshAccessTokenIfNeeded: vi.fn(), + mockUploadBufferToDrive: vi.fn(), + mockVerifyWorkspaceMembership: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, + fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +})) + +vi.mock('@/app/api/auth/oauth/utils', () => ({ + refreshAccessTokenIfNeeded: mockRefreshAccessTokenIfNeeded, +})) + +vi.mock('@/lib/google-drive/upload-to-drive', () => ({ + uploadBufferToDrive: mockUploadBufferToDrive, +})) + +vi.mock('@/app/api/workflows/utils', () => ({ + verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +})) + +const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' + +import { POST } from '@/app/api/workspaces/[id]/files/export-to-drive/route' + +const params = (id = WS) => ({ params: Promise.resolve({ id }) }) + +const makeRequest = (body: unknown) => + new NextRequest(`http://localhost/api/workspaces/${WS}/files/export-to-drive`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + +const fileRecord = (id: string, name: string, type = 'application/pdf') => ({ + id, + name, + type, + key: `workspace/${WS}/${id}-${name}`, + storageContext: 'workspace', +}) + +const validBody = { fileIds: ['file-1', 'file-2'], credentialId: 'cred-1' } + +describe('POST /api/workspaces/[id]/files/export-to-drive', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockVerifyWorkspaceMembership.mockResolvedValue('read') + mockRefreshAccessTokenIfNeeded.mockResolvedValue('access-token') + mockListWorkspaceFiles.mockResolvedValue([ + fileRecord('file-1', 'a.pdf'), + fileRecord('file-2', 'b.pdf'), + ]) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('content')) + mockUploadBufferToDrive.mockImplementation(({ name }: { name: string }) => + Promise.resolve({ id: `drive-${name}`, name, webViewLink: `https://drive/${name}` }) + ) + }) + + it('returns 401 when unauthenticated', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(401) + expect(mockUploadBufferToDrive).not.toHaveBeenCalled() + }) + + it('returns 403 when the user is not a workspace member', async () => { + mockVerifyWorkspaceMembership.mockResolvedValueOnce(null) + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(403) + expect(mockUploadBufferToDrive).not.toHaveBeenCalled() + }) + + it('returns 400 when the body is invalid (no files selected)', async () => { + const res = await POST(makeRequest({ fileIds: [], credentialId: 'cred-1' }), params()) + expect(res.status).toBe(400) + }) + + it('returns 400 when the Google Drive token cannot be resolved', async () => { + mockRefreshAccessTokenIfNeeded.mockResolvedValueOnce(null) + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + expect(res.status).toBe(400) + expect(body.error).toContain('not connected') + expect(mockUploadBufferToDrive).not.toHaveBeenCalled() + }) + + it('returns 400 when none of the requested files exist', async () => { + mockListWorkspaceFiles.mockResolvedValueOnce([fileRecord('other', 'x.pdf')]) + const res = await POST(makeRequest(validBody), params()) + expect(res.status).toBe(400) + expect(mockUploadBufferToDrive).not.toHaveBeenCalled() + }) + + it('exports all matching files and returns success', async () => { + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.success).toBe(true) + expect(body.exported).toHaveLength(2) + expect(body.failed).toHaveLength(0) + expect(body.exported[0]).toMatchObject({ fileId: 'file-1', driveFileId: 'drive-a.pdf' }) + expect(mockUploadBufferToDrive).toHaveBeenCalledTimes(2) + }) + + it('reports partial failure without aborting the batch', async () => { + mockUploadBufferToDrive + .mockResolvedValueOnce({ id: 'drive-a', name: 'a.pdf', webViewLink: 'https://drive/a' }) + .mockRejectedValueOnce(new Error('Drive quota exceeded')) + const res = await POST(makeRequest(validBody), params()) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.success).toBe(false) + expect(body.exported).toHaveLength(1) + expect(body.failed).toHaveLength(1) + expect(body.failed[0]).toMatchObject({ fileId: 'file-2', error: 'Drive quota exceeded' }) + }) + + it('reports requested files that no longer exist as failures', async () => { + mockListWorkspaceFiles.mockResolvedValueOnce([fileRecord('file-1', 'a.pdf')]) + const res = await POST( + makeRequest({ fileIds: ['file-1', 'file-gone'], credentialId: 'cred-1' }), + params() + ) + const body = await res.json() + expect(res.status).toBe(200) + expect(body.success).toBe(false) + expect(body.exported).toHaveLength(1) + expect(body.failed).toEqual([{ fileId: 'file-gone', error: 'File not found' }]) + expect(mockUploadBufferToDrive).toHaveBeenCalledTimes(1) + }) + + it('only exports files that match the requested ids', async () => { + await POST(makeRequest({ fileIds: ['file-1'], credentialId: 'cred-1' }), params()) + expect(mockUploadBufferToDrive).toHaveBeenCalledTimes(1) + expect(mockUploadBufferToDrive).toHaveBeenCalledWith( + expect.objectContaining({ name: 'a.pdf', accessToken: 'access-token' }) + ) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/export-to-drive/route.ts b/apps/sim/app/api/workspaces/[id]/files/export-to-drive/route.ts new file mode 100644 index 00000000000..b1c8957ab08 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/export-to-drive/route.ts @@ -0,0 +1,108 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { exportWorkspaceFilesToDriveContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { uploadBufferToDrive } from '@/lib/google-drive/upload-to-drive' +import { fetchWorkspaceFileBuffer, listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' +import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' + +const GOOGLE_DRIVE_PROVIDER_ID = 'google-drive' + +const logger = createLogger('WorkspaceFilesExportToDriveAPI') + +export const POST = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + const requestId = generateRequestId() + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = session.user.id + + const parsed = await parseRequest(exportWorkspaceFilesToDriveContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const { fileIds, credentialId } = parsed.data.body + + const permission = await verifyWorkspaceMembership(userId, workspaceId) + if (!permission) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + const accessToken = await refreshAccessTokenIfNeeded(credentialId, userId, requestId) + if (!accessToken) { + logger.warn(`[${requestId}] Could not resolve Google Drive access token`, { credentialId }) + return NextResponse.json( + { error: 'Google Drive account is not connected or its access has expired' }, + { status: 400 } + ) + } + + const allFiles = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }) + const fileById = new Map(allFiles.map((file) => [file.id, file])) + const filesToExport = fileIds + .map((id) => fileById.get(id)) + .filter((file): file is NonNullable => file !== undefined) + + if (filesToExport.length === 0) { + return NextResponse.json({ error: 'No matching files found to export' }, { status: 400 }) + } + + const exported: Array<{ + fileId: string + name: string + driveFileId: string + webViewLink?: string + }> = [] + // Requested ids that no longer exist (e.g. deleted between selection and export) + // are reported as failures so the client never shows a false full-success. + const failed: Array<{ fileId: string; name?: string; error: string }> = fileIds + .filter((id) => !fileById.has(id)) + .map((id) => ({ fileId: id, error: 'File not found' })) + + for (const file of filesToExport) { + try { + const buffer = await fetchWorkspaceFileBuffer(file) + const driveFile = await uploadBufferToDrive({ + accessToken, + name: file.name, + mimeType: file.type || 'application/octet-stream', + buffer, + }) + exported.push({ + fileId: file.id, + name: file.name, + driveFileId: driveFile.id, + webViewLink: driveFile.webViewLink, + }) + } catch (error) { + const message = getErrorMessage(error, 'Failed to export file') + logger.error(`[${requestId}] Failed to export file to Google Drive`, { + fileId: file.id, + name: file.name, + error: message, + }) + failed.push({ fileId: file.id, name: file.name, error: message }) + } + } + + logger.info(`[${requestId}] Export to Google Drive complete`, { + provider: GOOGLE_DRIVE_PROVIDER_ID, + workspaceId, + exported: exported.length, + failed: failed.length, + }) + + return NextResponse.json({ + success: failed.length === 0, + exported, + failed, + }) + } +) diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx index 3ab1ee6ec03..2c75fe76e58 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx @@ -148,6 +148,7 @@ type ConnectOAuthModalConnectProps = ConnectOAuthModalBaseProps & { | { origin: 'workflow'; workflowId: string } | { origin: 'kb-connectors'; knowledgeBaseId: string; connectorType?: string } | { origin: 'integrations' } + | { origin: 'files' } ) /** @@ -319,6 +320,8 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) { } } else if (props.origin === 'workflow') { returnContext = { ...baseContext, origin: 'workflow', workflowId: props.workflowId } + } else if (props.origin === 'files') { + returnContext = { ...baseContext, origin: 'files' } } else { returnContext = { ...baseContext, origin: 'integrations' } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx index 9a2ae9b93d1..b3f126541df 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx @@ -13,6 +13,7 @@ import { Trash, } from '@/components/emcn' import { Download } from '@/components/emcn/icons' +import { GoogleDriveIcon } from '@/components/icons' import { cn } from '@/lib/core/utils/cn' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' @@ -20,6 +21,7 @@ import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-optio interface FilesActionBarProps { selectedCount: number onDownload?: () => void + onExportToDrive?: () => void onMove?: (optionValue: string) => void moveOptions?: MoveOptionNode[] onDelete?: () => void @@ -30,6 +32,7 @@ interface FilesActionBarProps { export function FilesActionBar({ selectedCount, onDownload, + onExportToDrive, onMove, moveOptions, onDelete, @@ -55,21 +58,49 @@ export function FilesActionBar({ {selectedCount} selected
- {onDownload && ( - - - - - Download - - )} + {onDownload && + (onExportToDrive ? ( + + + + + + + + Export + + + + + Download + + + + Google Drive + + + + ) : ( + + + + + Download + + ))} {onMove && moveOptions && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/export-to-drive-modal/export-to-drive-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/export-to-drive-modal/export-to-drive-modal.tsx new file mode 100644 index 00000000000..c536165c0a5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/export-to-drive-modal/export-to-drive-modal.tsx @@ -0,0 +1,188 @@ +'use client' + +import { useState } from 'react' +import { Plus } from 'lucide-react' +import { + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + toast, +} from '@/components/emcn' +import { GoogleDriveIcon } from '@/components/icons' +import { getProviderIdFromServiceId, getScopesForService, type OAuthProvider } from '@/lib/oauth' +import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' +import { writePendingDriveExport } from '@/app/workspace/[workspaceId]/files/pending-export' +import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' +import { useExportWorkspaceFilesToDrive } from '@/hooks/queries/workspace-files' +import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' + +const GOOGLE_DRIVE_SERVICE_ID = 'google-drive' +const CONNECT_NEW_VALUE = '__connect_new__' + +interface ExportToDriveModalProps { + open: boolean + onOpenChange: (open: boolean) => void + workspaceId: string + /** Workspace file ids to export. */ + fileIds: string[] + /** File names, used to label the destination hint and success toast. */ + fileNames: string[] + /** + * When the modal is resumed after connecting an account, the credential ids + * that existed beforehand. The account not in this list is the newly-connected + * one and is auto-selected, so Export is ready even with several accounts. + */ + priorCredentialIds?: string[] +} + +/** + * Picks a connected Google Drive account and exports the given workspace files + * to the root of that account's Drive, reusing the same OAuth credentials the + * Google Drive block uses. Offers an inline "Connect account" path for users + * who have not connected Google Drive yet. + */ +export function ExportToDriveModal({ + open, + onOpenChange, + workspaceId, + fileIds, + fileNames, + priorCredentialIds, +}: ExportToDriveModalProps) { + const providerId = getProviderIdFromServiceId(GOOGLE_DRIVE_SERVICE_ID) as OAuthProvider + + const [selectedCredentialId, setSelectedCredentialId] = useState(null) + const [showOAuthModal, setShowOAuthModal] = useState(false) + + const { + data: credentials = [], + isLoading: credentialsLoading, + refetch: refetchCredentials, + } = useOAuthCredentials(providerId, { enabled: open, workspaceId }) + + useCredentialRefreshTriggers(refetchCredentials, providerId, workspaceId) + + const { mutate: exportToDrive, isPending } = useExportWorkspaceFilesToDrive() + + const newlyConnectedId = priorCredentialIds + ? credentials.find((credential) => !priorCredentialIds.includes(credential.id))?.id + : undefined + + const effectiveCredentialId = + selectedCredentialId ?? + newlyConnectedId ?? + (credentials.length === 1 ? credentials[0].id : null) + + const accountOptions = [ + ...credentials.map((credential) => ({ + value: credential.id, + label: credential.name, + icon: GoogleDriveIcon, + })), + { + value: CONNECT_NEW_VALUE, + label: credentials.length > 0 ? 'Connect another account' : 'Connect Google Drive account', + icon: Plus, + }, + ] + + const handleSelectAccount = (value: string) => { + if (value === CONNECT_NEW_VALUE) { + // Connecting triggers a full-page OAuth redirect, so persist the selection + // (and the current accounts) to resume this export — with the new account + // auto-selected — when the user returns to Files. + writePendingDriveExport({ + fileIds, + fileNames, + priorCredentialIds: credentials.map((credential) => credential.id), + }) + setShowOAuthModal(true) + return + } + setSelectedCredentialId(value) + } + + const handleExport = () => { + if (!effectiveCredentialId) return + exportToDrive( + { workspaceId, fileIds, credentialId: effectiveCredentialId }, + { + onSuccess: (data) => { + if (data.exported.length === 0) { + toast.error(data.failed[0]?.error ?? 'Failed to export to Google Drive') + return + } + + const link = data.exported[0].webViewLink + const summary = + data.failed.length === 0 + ? `Exported ${data.exported.length} ${data.exported.length === 1 ? 'file' : 'files'} to Google Drive` + : `Exported ${data.exported.length} of ${data.exported.length + data.failed.length} files to Google Drive` + + toast.success(summary, { + action: link + ? { label: 'Open', onClick: () => window.open(link, '_blank') } + : undefined, + }) + onOpenChange(false) + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) + } + + const destinationLabel = + fileNames.length === 1 + ? `"${fileNames[0]}"` + : `${fileNames.length} ${fileNames.length === 1 ? 'file' : 'files'}` + + return ( + <> + + onOpenChange(false)}> + Export to Google Drive + + + + + onOpenChange(false)} + cancelDisabled={isPending} + primaryAction={{ + label: isPending ? 'Exporting…' : 'Export', + onClick: handleExport, + disabled: !effectiveCredentialId || isPending, + }} + /> + + {showOAuthModal && ( + + )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/export-to-drive-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/export-to-drive-modal/index.ts new file mode 100644 index 00000000000..4a01d3df30f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/export-to-drive-modal/index.ts @@ -0,0 +1 @@ +export { ExportToDriveModal } from './export-to-drive-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx index db0853d2a3c..2a6fe3c724b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx @@ -16,6 +16,7 @@ import { Pencil, } from '@/components/emcn' import { Download, Trash } from '@/components/emcn/icons' +import { GoogleDriveIcon } from '@/components/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' @@ -25,6 +26,7 @@ interface FileRowContextMenuProps { onClose: () => void onOpen: () => void onDownload?: () => void + onExportToDrive?: () => void onRename: () => void onDelete: () => void onMove?: (optionValue: string) => void @@ -39,6 +41,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ onClose, onOpen, onDownload, + onExportToDrive, onRename, onDelete, onMove, @@ -70,12 +73,30 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ Open )} - {onDownload && ( - - - {isMultiSelect ? `Download ${selectedCount} items` : 'Download'} - - )} + {onDownload && + (onExportToDrive ? ( + + + + {isMultiSelect ? `Export ${selectedCount} items` : 'Export'} + + + + + Download + + + + Google Drive + + + + ) : ( + + + {isMultiSelect ? `Download ${selectedCount} items` : 'Download'} + + ))} {canEdit && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 2358d61182f..4f44a757dd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -23,6 +23,7 @@ import { Upload, } from '@/components/emcn' import { Download } from '@/components/emcn/icons' +import { GoogleDriveIcon } from '@/components/icons' import { getDocumentIcon } from '@/components/icons/document-icons' import { captureEvent } from '@/lib/posthog/client' import { triggerFileDownload } from '@/lib/uploads/client/download' @@ -61,6 +62,7 @@ import { } from '@/app/workspace/[workspaceId]/components' import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' +import { ExportToDriveModal } from '@/app/workspace/[workspaceId]/files/components/export-to-drive-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { @@ -70,6 +72,10 @@ import { } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' +import { + clearPendingDriveExport, + consumePendingDriveExport, +} from '@/app/workspace/[workspaceId]/files/pending-export' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' @@ -89,6 +95,7 @@ import { } from '@/hooks/queries/workspace-files' import { useDebounce } from '@/hooks/use-debounce' import { useInlineRename } from '@/hooks/use-inline-rename' +import { useOAuthReturnForFiles } from '@/hooks/use-oauth-return' import { usePermissionConfig } from '@/hooks/use-permission-config' type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' @@ -173,6 +180,8 @@ export function Files() { const currentFolderId = searchParams.get('folderId') const workspaceId = params?.workspaceId as string + useOAuthReturnForFiles(workspaceId) + const posthog = usePostHog() const posthogRef = useRef(posthog) posthogRef.current = posthog @@ -269,6 +278,24 @@ export function Files() { folderIds: string[] name: string } | null>(null) + const [exportTarget, setExportTarget] = useState<{ + fileIds: string[] + fileNames: string[] + priorCredentialIds?: string[] + } | null>(null) + + // Resume an export that was paused to connect a Google Drive account (the OAuth + // flow is a full-page redirect, so the in-flight selection is restored on return). + useEffect(() => { + const pending = consumePendingDriveExport() + if (pending) { + setExportTarget({ + fileIds: pending.fileIds, + fileNames: pending.fileNames, + priorCredentialIds: pending.priorCredentialIds, + }) + } + }, []) const listRename = useInlineRename({ onSave: (rowId, name) => { @@ -1029,6 +1056,35 @@ export function Files() { window.location.href = `/api/workspaces/${workspaceId}/files/download?${query.toString()}` }, [selectedFileIds, selectedFolderIds, files, handleDownload, workspaceId]) + const openExportToDrive = useCallback((targetFiles: WorkspaceFileRecord[]) => { + if (targetFiles.length === 0) return + setExportTarget({ + fileIds: targetFiles.map((file) => file.id), + fileNames: targetFiles.map((file) => file.name), + }) + }, []) + + const handleExportSelectedToDrive = useCallback(() => { + const file = selectedFileRef.current + if (file) openExportToDrive([file]) + }, [openExportToDrive]) + + const handleBulkExportToDrive = useCallback(() => { + openExportToDrive(files.filter((file) => selectedFileIds.includes(file.id))) + }, [files, selectedFileIds, openExportToDrive]) + + const handleContextMenuExportToDrive = useCallback(() => { + const item = contextMenuItemRef.current + if (!item) return + const rowId = item.kind === 'file' ? fileRowId(item.file.id) : folderRowId(item.folder.id) + if (selectedRowIds.has(rowId) && selectedRowIds.size > 1) { + openExportToDrive(files.filter((file) => selectedFileIds.includes(file.id))) + } else if (item.kind === 'file') { + openExportToDrive([item.file]) + } + closeContextMenu() + }, [selectedRowIds, selectedFileIds, files, openExportToDrive, closeContextMenu]) + const fileDetailBreadcrumbs = useMemo(() => { if (!selectedFile) return [] @@ -1453,6 +1509,11 @@ export function Files() { icon: Download, onSelect: handleDownloadSelected, }, + { + text: 'Export to Drive', + icon: GoogleDriveIcon, + onSelect: handleExportSelectedToDrive, + }, ...(canEdit ? [ { @@ -1473,6 +1534,7 @@ export function Files() { handleTogglePreview, handleSave, handleDownloadSelected, + handleExportSelectedToDrive, handleDeleteSelected, ]) @@ -1925,6 +1987,7 @@ export function Files() { 0 ? handleBulkExportToDrive : undefined} onMove={canEdit ? handleContextMenuMove : undefined} moveOptions={canEdit ? contextMenuMoveOptions : undefined} onDelete={canEdit ? handleBulkDelete : undefined} @@ -1966,6 +2029,12 @@ export function Files() { onClose={closeContextMenu} onOpen={handleContextMenuOpen} onDownload={handleContextMenuDownload} + onExportToDrive={ + contextMenuItemRef.current?.kind === 'file' || + (selectedRowIds.size > 1 && selectedFileIds.length > 0) + ? handleContextMenuExportToDrive + : undefined + } onRename={handleContextMenuRename} onDelete={handleContextMenuDelete} onMove={handleContextMenuMove} @@ -1974,6 +2043,24 @@ export function Files() { selectedCount={selectedRowIds.size} /> + {exportTarget !== null && ( + { + if (!open) { + // Dismissing the flow (vs. an OAuth redirect, which unloads the page) + // discards any pending resume token so a cancel never reopens later. + clearPendingDriveExport() + setExportTarget(null) + } + }} + workspaceId={workspaceId} + fileIds={exportTarget.fileIds} + fileNames={exportTarget.fileNames} + priorCredentialIds={exportTarget.priorCredentialIds} + /> + )} + ({ + mutationFn: ({ workspaceId, fileIds, credentialId }: ExportFilesToDriveParams) => + requestJson(exportWorkspaceFilesToDriveContract, { + params: { id: workspaceId }, + body: { fileIds, credentialId }, + }), + onError: (error) => { + logger.error('Failed to export files to Google Drive:', error) + }, + }) +} + export function useRestoreWorkspaceFile() { const queryClient = useQueryClient() diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 897adb9dffb..447e744cba9 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -103,6 +103,14 @@ export function useOAuthReturnRouter() { router.replace(`${kbUrl}${connectorParam}`) return } + + if (ctx.origin === 'files') { + try { + sessionStorage.removeItem(SETTINGS_RETURN_URL_KEY) + } catch {} + router.replace(`/workspace/${workspaceId}/files`) + return + } }, [router, workspaceId]) } @@ -145,3 +153,24 @@ export function useOAuthReturnForKBConnectors(knowledgeBaseId: string) { })() }, [knowledgeBaseId]) } + +/** + * Post-OAuth handler for the Files page. + * Consumes the return context and shows a toast notification after a Google + * Drive (or other provider) account is connected from the export-to-Drive flow. + */ +export function useOAuthReturnForFiles(workspaceId: string) { + useEffect(() => { + const ctx = readOAuthReturnContext() + if (!ctx || ctx.origin !== 'files') return + if (ctx.workspaceId !== workspaceId) return + consumeOAuthReturnContext() + if (Date.now() - ctx.requestedAt > CONTEXT_MAX_AGE_MS) return + + void (async () => { + const message = await resolveOAuthMessage(ctx) + toast.success(message) + dispatchCredentialUpdate(ctx) + })() + }, [workspaceId]) +} diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 7ea83ca4c5f..371f9b4cfc5 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -246,3 +246,54 @@ export const registerWorkspaceFileContract = defineRouteContract({ schema: registerWorkspaceFileResponseSchema, }, }) + +const MAX_EXPORT_TO_DRIVE_FILES = 50 + +export const exportWorkspaceFilesToDriveBodySchema = z.object({ + fileIds: z + .array(z.string().min(1, 'File ID cannot be empty')) + .min(1, 'Select at least one file to export') + .max( + MAX_EXPORT_TO_DRIVE_FILES, + `Cannot export more than ${MAX_EXPORT_TO_DRIVE_FILES} files at once` + ), + credentialId: z + .string({ error: 'Google Drive account is required' }) + .min(1, 'Google Drive account is required'), +}) + +export type ExportWorkspaceFilesToDriveBody = z.input + +const exportedDriveFileSchema = z.object({ + fileId: z.string(), + name: z.string(), + driveFileId: z.string(), + webViewLink: z.string().optional(), +}) + +const failedDriveExportSchema = z.object({ + fileId: z.string(), + name: z.string().optional(), + error: z.string(), +}) + +const exportWorkspaceFilesToDriveResponseSchema = z.object({ + success: z.boolean(), + exported: z.array(exportedDriveFileSchema), + failed: z.array(failedDriveExportSchema), +}) + +export type ExportWorkspaceFilesToDriveResponse = z.output< + typeof exportWorkspaceFilesToDriveResponseSchema +> + +export const exportWorkspaceFilesToDriveContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/files/export-to-drive', + params: workspaceFilesParamsSchema, + body: exportWorkspaceFilesToDriveBodySchema, + response: { + mode: 'json', + schema: exportWorkspaceFilesToDriveResponseSchema, + }, +}) diff --git a/apps/sim/lib/credentials/client-state.ts b/apps/sim/lib/credentials/client-state.ts index 70d52d95fa2..35b90fcc8d9 100644 --- a/apps/sim/lib/credentials/client-state.ts +++ b/apps/sim/lib/credentials/client-state.ts @@ -72,7 +72,7 @@ export const ADD_CONNECTOR_SEARCH_PARAM = 'addConnector' as const const OAUTH_RETURN_CONTEXT_KEY = 'sim.oauth-return-context' -export type OAuthReturnOrigin = 'workflow' | 'integrations' | 'kb-connectors' +export type OAuthReturnOrigin = 'workflow' | 'integrations' | 'kb-connectors' | 'files' interface OAuthReturnBase { displayName: string @@ -98,10 +98,15 @@ interface OAuthReturnKBConnectors extends OAuthReturnBase { connectorType?: string } +interface OAuthReturnFiles extends OAuthReturnBase { + origin: 'files' +} + export type OAuthReturnContext = | OAuthReturnWorkflow | OAuthReturnIntegrations | OAuthReturnKBConnectors + | OAuthReturnFiles export function writeOAuthReturnContext(ctx: OAuthReturnContext) { if (typeof window === 'undefined') return diff --git a/apps/sim/lib/google-drive/upload-to-drive.test.ts b/apps/sim/lib/google-drive/upload-to-drive.test.ts new file mode 100644 index 00000000000..b2751144e26 --- /dev/null +++ b/apps/sim/lib/google-drive/upload-to-drive.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { DriveUploadError, uploadBufferToDrive } from '@/lib/google-drive/upload-to-drive' + +function jsonResponse( + body: unknown, + init?: { ok?: boolean; status?: number; statusText?: string } +) { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + statusText: init?.statusText ?? 'OK', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response +} + +const baseParams = { + accessToken: 'token', + name: 'report.pdf', + mimeType: 'application/pdf', + buffer: Buffer.from('data'), +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('uploadBufferToDrive', () => { + it('uploads and returns the created file metadata', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ id: 'drive-1' })) + .mockResolvedValueOnce(jsonResponse({ id: 'drive-1', name: 'report.pdf', webViewLink: 'x' })) + vi.stubGlobal('fetch', fetchMock) + + const file = await uploadBufferToDrive(baseParams) + expect(file).toMatchObject({ id: 'drive-1', name: 'report.pdf', webViewLink: 'x' }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('throws DriveUploadError when the multipart upload fails', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({}, { ok: false, status: 403, statusText: 'Forbidden' })) + vi.stubGlobal('fetch', fetchMock) + + await expect(uploadBufferToDrive(baseParams)).rejects.toMatchObject({ + name: 'DriveUploadError', + status: 403, + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('throws DriveUploadError when the final metadata fetch fails', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ id: 'drive-1' })) + .mockResolvedValueOnce( + jsonResponse( + { error: 'busy' }, + { ok: false, status: 503, statusText: 'Service Unavailable' } + ) + ) + vi.stubGlobal('fetch', fetchMock) + + await expect(uploadBufferToDrive(baseParams)).rejects.toBeInstanceOf(DriveUploadError) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/google-drive/upload-to-drive.ts b/apps/sim/lib/google-drive/upload-to-drive.ts new file mode 100644 index 00000000000..fbee24ca6b5 --- /dev/null +++ b/apps/sim/lib/google-drive/upload-to-drive.ts @@ -0,0 +1,163 @@ +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' + +const logger = createLogger('GoogleDriveUpload') + +const GOOGLE_DRIVE_UPLOAD_BASE = 'https://www.googleapis.com/upload/drive/v3/files' +const GOOGLE_DRIVE_FILES_BASE = 'https://www.googleapis.com/drive/v3/files' + +const FINAL_FILE_FIELDS = + 'id,name,mimeType,webViewLink,webContentLink,size,createdTime,modifiedTime,parents' + +/** A file as returned by the Drive `files` resource after upload. */ +export interface DriveUploadedFile { + id: string + name: string + mimeType: string + webViewLink?: string + webContentLink?: string + size?: string + createdTime?: string + modifiedTime?: string + parents?: string[] +} + +export interface UploadBufferToDriveParams { + accessToken: string + /** File name to create in Drive. */ + name: string + /** + * MIME type Drive should store the file as. May be a Google Workspace type + * (e.g. `application/vnd.google-apps.spreadsheet`) to request conversion. + */ + mimeType: string + /** + * Content-Type of the bytes being uploaded. Defaults to {@link mimeType}. + * When this differs from {@link mimeType}, Drive performs a format conversion + * and the created file's name is re-applied so it survives the conversion. + */ + uploadMimeType?: string + buffer: Buffer + /** Optional parent folder id. When omitted, the file lands in My Drive root. */ + folderId?: string +} + +/** Error thrown when the Google Drive API rejects an upload, carrying the HTTP status. */ +export class DriveUploadError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message) + this.name = 'DriveUploadError' + } +} + +/** + * Build the `multipart/related` request body for a Google Drive upload — a JSON + * metadata part followed by the base64-encoded file part. + */ +function buildMultipartBody( + metadata: Record, + fileBuffer: Buffer, + mimeType: string, + boundary: string +): string { + return [ + `--${boundary}`, + 'Content-Type: application/json; charset=UTF-8', + '', + JSON.stringify(metadata), + `--${boundary}`, + `Content-Type: ${mimeType}`, + 'Content-Transfer-Encoding: base64', + '', + fileBuffer.toString('base64'), + `--${boundary}--`, + ].join('\r\n') +} + +/** + * Upload a file buffer to Google Drive via a multipart upload and return the + * created file's metadata. Shared by the Google Drive workflow tool and the + * workspace Files "Export to Drive" action so the upload path lives in one place. + */ +export async function uploadBufferToDrive( + params: UploadBufferToDriveParams +): Promise { + const { accessToken, name, mimeType, buffer, folderId } = params + const uploadMimeType = params.uploadMimeType ?? mimeType + + const metadata: { name: string; mimeType: string; parents?: string[] } = { + name, + mimeType, + } + if (folderId && folderId.trim() !== '') { + metadata.parents = [folderId.trim()] + } + + const boundary = `boundary_${generateShortId(12)}` + const multipartBody = buildMultipartBody(metadata, buffer, uploadMimeType, boundary) + + const uploadResponse = await fetch( + `${GOOGLE_DRIVE_UPLOAD_BASE}?uploadType=multipart&supportsAllDrives=true`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': `multipart/related; boundary=${boundary}`, + 'Content-Length': Buffer.byteLength(multipartBody, 'utf-8').toString(), + }, + body: multipartBody, + } + ) + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text() + throw new DriveUploadError( + `Google Drive API error: ${uploadResponse.statusText || errorText || 'upload failed'}`, + uploadResponse.status + ) + } + + const uploaded = (await uploadResponse.json()) as { id: string } + const fileId = uploaded.id + + // A format conversion can drop the requested name; re-apply it so it persists. + // This is best-effort — the file is already uploaded — but a failure is logged + // so a mis-named converted file leaves a diagnostic trace. + if (uploadMimeType !== mimeType) { + const patchResponse = await fetch( + `${GOOGLE_DRIVE_FILES_BASE}/${fileId}?supportsAllDrives=true`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name }), + } + ) + if (!patchResponse.ok) { + logger.warn('Failed to re-apply file name after Drive conversion', { + fileId, + status: patchResponse.status, + }) + } + } + + const finalResponse = await fetch( + `${GOOGLE_DRIVE_FILES_BASE}/${fileId}?supportsAllDrives=true&fields=${FINAL_FILE_FIELDS}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ) + + if (!finalResponse.ok) { + const errorText = await finalResponse.text() + throw new DriveUploadError( + `Google Drive API error fetching file metadata: ${finalResponse.statusText || errorText || 'unknown error'}`, + finalResponse.status + ) + } + + return (await finalResponse.json()) as DriveUploadedFile +}