feat: Google Drive backup upload endpoint (Faz 2 — backend half)
POST /api/google-drive/upload: Electron'un backupQueue'sunun gönderdiği tek dosyayı alıp Google'ın resumable upload session'ıyla Drive'a yazıyor (mevcutsa aynı drive_file_id'yi PATCH ederek yeni sürüm olarak, PRD Test 4). Dava başlığı = Drive'daki alt klasör adı (lokal yapı korunuyor). GET /backup-status: Ayarlar > Yedekleme paneli için toplam dosya/boyut/son senkron özeti. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
328aef91bd
commit
313d5b72ef
@@ -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/<dava başlığı>/<dosya>).
|
||||
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ı.' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function getUserEmail(accessToken: string): Promise<string> {
|
||||
// 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<string> {
|
||||
export async function findOrCreateFolder(accessToken: string, name: string, parentId?: string): Promise<string> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user