diff --git a/src/controllers/googleDrive.controller.ts b/src/controllers/googleDrive.controller.ts index 7cbe75b..e2fece1 100644 --- a/src/controllers/googleDrive.controller.ts +++ b/src/controllers/googleDrive.controller.ts @@ -185,3 +185,140 @@ export const refresh = async (req: AuthenticatedRequest, res: Response) => { res.status(409).json({ error: error.message || 'Google Drive bağlantısı yenilenemedi.' }); } }; + +// Electron'un backupQueue.js'i tek bir dosyayı burada yükler — Google Drive +// çağrısı (resumable upload) tamamen burada yapılıyor, Electron hiçbir zaman +// Google token'ı görmüyor. PRD §9: local yapı korunuyor, dava başlığı = Drive +// klasör adı (Davalar//). +export const uploadFile = async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.id as string; + const file = req.file; + const { case_title, local_path, local_modified_at, sha256 } = req.body || {}; + + if (!file || !case_title || !local_path) { + return res.status(400).json({ error: 'file, case_title ve local_path zorunludur.' }); + } + + const { data: connection } = await supabase + .from('google_drive_connections') + .select('drive_cases_folder_id, status') + .eq('user_id', userId) + .maybeSingle(); + if (!connection || connection.status !== 'connected' || !connection.drive_cases_folder_id) { + return res.status(409).json({ error: 'Google Drive bağlantısı bulunamadı.' }); + } + + let accessToken: string; + try { + accessToken = await getValidAccessToken(userId); + } catch (err: any) { + return res.status(409).json({ error: err.message || 'Google Drive bağlantınızın yenilenmesi gerekiyor.' }); + } + + const caseFolderId = await googleDrive.findOrCreateCaseFolder(accessToken, connection.drive_cases_folder_id, case_title); + + const { data: existingFile } = await supabase + .from('google_drive_files') + .select('id, drive_file_id') + .eq('user_id', userId) + .eq('local_path', local_path) + .maybeSingle(); + + console.log('[DRIVE] Upload started:', local_path); + await supabase.from('google_drive_backup_events').insert({ + user_id: userId, + file_id: existingFile?.id || null, + event_type: 'upload_started', + file_size: file.size, + }); + + let driveResult; + try { + driveResult = await googleDrive.uploadFileResumable(accessToken, { + name: file.originalname, + parentId: caseFolderId, + mimeType: file.mimetype || 'application/octet-stream', + buffer: file.buffer, + existingFileId: existingFile?.drive_file_id || undefined, + }); + } catch (uploadErr: any) { + console.error('[DRIVE] Upload failed:', local_path, uploadErr.message); + await supabase.from('google_drive_backup_events').insert({ + user_id: userId, + file_id: existingFile?.id || null, + event_type: 'upload_failed', + file_size: file.size, + error_message: uploadErr.message, + }); + throw uploadErr; + } + + const { data: fileRow, error: upsertError } = await supabase + .from('google_drive_files') + .upsert( + { + user_id: userId, + local_path, + drive_file_id: driveResult.id, + drive_parent_id: caseFolderId, + file_name: file.originalname, + file_size: file.size, + local_modified_at: local_modified_at || null, + drive_modified_at: new Date().toISOString(), + sha256: sha256 || null, + status: 'synced', + updated_at: new Date().toISOString(), + }, + { onConflict: 'user_id,local_path' } + ) + .select('id') + .single(); + if (upsertError) throw upsertError; + + await supabase.from('google_drive_backup_events').insert({ + user_id: userId, + file_id: fileRow.id, + event_type: 'upload_completed', + file_size: file.size, + status: 'synced', + }); + await supabase.from('google_drive_connections').update({ last_sync_at: new Date().toISOString() }).eq('user_id', userId); + + console.log('[DRIVE] Upload completed:', local_path); + res.json({ ok: true, drive_file_id: driveResult.id }); + } catch (error: any) { + console.error('[DRIVE] Upload Error:', error); + // PRD §41: teknik detay kullanıcıya gitmiyor, sadece backend logunda kalıyor. + res.status(500).json({ error: 'Dosya yedeklenemedi. İnternet bağlantınızı kontrol edin.' }); + } +}; + +export const getBackupStatus = async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.id; + const { data: files, error } = await supabase + .from('google_drive_files') + .select('status, file_size') + .eq('user_id', userId); + if (error) throw error; + + const { data: connection } = await supabase + .from('google_drive_connections') + .select('last_sync_at') + .eq('user_id', userId) + .maybeSingle(); + + const synced = (files || []).filter((f) => f.status === 'synced'); + const totalSize = synced.reduce((sum, f) => sum + (f.file_size || 0), 0); + + res.json({ + total_files: synced.length, + total_size: totalSize, + last_sync_at: connection?.last_sync_at || null, + }); + } catch (error: any) { + console.error('[DRIVE] Get Backup Status Error:', error); + res.status(500).json({ error: 'Yedekleme durumu alınamadı.' }); + } +}; diff --git a/src/lib/googleDriveClient.ts b/src/lib/googleDriveClient.ts index e8bf631..f9a5988 100644 --- a/src/lib/googleDriveClient.ts +++ b/src/lib/googleDriveClient.ts @@ -110,7 +110,7 @@ export async function getUserEmail(accessToken: string): Promise { // drive.file scope'u sadece uygulamanın oluşturduğu/açtığı dosyaları görmesine // izin verir — bu yüzden klasörü her zaman biz oluşturuyoruz/arıyoruz, kullanıcının // genel Drive'ında serbest arama yapamayız (zaten istemiyoruz, bkz. PRD §7). -async function findOrCreateFolder(accessToken: string, name: string, parentId?: string): Promise { +export async function findOrCreateFolder(accessToken: string, name: string, parentId?: string): Promise { const conditions = [`name='${name.replace(/'/g, "\\'")}'`, "mimeType='application/vnd.google-apps.folder'", 'trashed=false']; if (parentId) conditions.push(`'${parentId}' in parents`); @@ -146,3 +146,73 @@ export async function ensureAyrisLegalFolders(accessToken: string): Promise<{ ro const casesFolderId = await findOrCreateFolder(accessToken, 'Davalar', rootFolderId); return { rootFolderId, casesFolderId }; } + +// PRD §9: local dosya yapısı (dava klasörü → belgeler) birebir Drive'a +// yansıtılıyor — dava başlığı = klasör adı. drive.file scope zaten sadece +// uygulamanın oluşturduğu klasörleri görebildiği için burada da arama/oluşturma +// findOrCreateFolder ile aynı mantıkla çalışıyor. +export async function findOrCreateCaseFolder(accessToken: string, casesFolderId: string, caseTitle: string): Promise { + return findOrCreateFolder(accessToken, caseTitle, casesFolderId); +} + +interface UploadParams { + name: string; + parentId: string; + mimeType: string; + buffer: Buffer; + // Verilirse içerik bu mevcut Drive dosyasının YENİ SÜRÜMÜ olarak yüklenir + // (Google Drive eski sürümü otomatik revizyon geçmişinde tutar) — yeni bir + // kopya dosya OLUŞTURULMAZ. PRD Test 4 "Modified File" tam olarak bu. + existingFileId?: string; +} + +// PRD §16: Google Drive'ın resumable upload protokolü kullanılıyor (electron-updater +// gibi burada da sıfırdan bir upload mekanizması yazmak yerine Google'ın kendi +// resumable session'ı kullanılıyor). Dosya baytları backend'e tek seferde (multer, +// bellekte buffer olarak) geldiği için PUT tek parça gönderiliyor — asıl fayda +// backend↔Google bacağının bu sayede Google'ın kendi bütünlük/aktarım garantisini +// kullanması. Not: Electron↔backend bacağı için bayt-bazlı devam etme (resume) +// henüz yok, bkz. PR açıklaması — kuyruk seviyesinde tüm dosya yeniden denenir. +export async function uploadFileResumable(accessToken: string, params: UploadParams): Promise<{ id: string; modifiedTime?: string }> { + const isUpdate = !!params.existingFileId; + const sessionUrl = isUpdate + ? `https://www.googleapis.com/upload/drive/v3/files/${params.existingFileId}?uploadType=resumable` + : `https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable`; + const metadata = isUpdate ? {} : { name: params.name, parents: [params.parentId] }; + + const sessionRes = await fetch(sessionUrl, { + method: isUpdate ? 'PATCH' : 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json; charset=UTF-8', + 'X-Upload-Content-Type': params.mimeType, + 'X-Upload-Content-Length': String(params.buffer.length), + }, + body: JSON.stringify(metadata), + }); + if (!sessionRes.ok) { + const err = await sessionRes.json().catch(() => ({})); + throw new Error(err.error?.message || 'Google Drive upload oturumu başlatılamadı.'); + } + const sessionUri = sessionRes.headers.get('location'); + if (!sessionUri) { + throw new Error('Google Drive upload oturum adresi alınamadı.'); + } + + const uploadRes = await fetch(sessionUri, { + method: 'PUT', + headers: { + 'Content-Type': params.mimeType, + 'Content-Length': String(params.buffer.length), + }, + // Node'un fetch implementasyonu (undici) Buffer'ı runtime'da sorunsuz kabul + // ediyor — DOM tip tanımları BodyInit'e Buffer'ı dahil etmediği için sadece + // tip seviyesinde cast gerekiyor. + body: params.buffer as unknown as BodyInit, + }); + const data = await uploadRes.json().catch(() => ({})); + if (!uploadRes.ok) { + throw new Error(data.error?.message || `Google Drive upload başarısız (HTTP ${uploadRes.status}).`); + } + return data; +} diff --git a/src/routes/googleDrive.routes.ts b/src/routes/googleDrive.routes.ts index 811daa8..e28dc29 100644 --- a/src/routes/googleDrive.routes.ts +++ b/src/routes/googleDrive.routes.ts @@ -1,8 +1,10 @@ import { Router } from 'express'; +import multer from 'multer'; import { requireAuth } from '../middleware/auth'; -import { connect, oauthCallback, getStatus, disconnect, refresh } from '../controllers/googleDrive.controller'; +import { connect, oauthCallback, getStatus, disconnect, refresh, uploadFile, getBackupStatus } from '../controllers/googleDrive.controller'; const router = Router(); +const upload = multer({ storage: multer.memoryStorage() }); // Google'ın doğrudan (Bearer token olmadan) yönlendirdiği tek public uç nokta. router.get('/oauth/callback', oauthCallback); @@ -11,5 +13,7 @@ router.get('/connect', requireAuth, connect); router.get('/status', requireAuth, getStatus); router.post('/disconnect', requireAuth, disconnect); router.post('/refresh', requireAuth, refresh); +router.post('/upload', upload.single('file'), requireAuth, uploadFile); +router.get('/backup-status', requireAuth, getBackupStatus); export default router;