From 746848606a3dc69c5f19d68e54b74e6ff70d9c01 Mon Sep 17 00:00:00 2001 From: Mustafa Yildiz Date: Thu, 20 Aug 2026 00:44:12 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20mobil=20i=C3=A7in=20Google=20Drive=20in?= =?UTF-8?q?ce-k=C3=B6pr=C3=BC=20endpoint'i=20ekle=20(Faz=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yeni GET /api/mobile-relay/case/:caseId/summary — kullanıcının Drive'ındaki case.sqlite'ı anlık olarak açıp sadece belge metadata'sı (extracted_text HARİÇ) ve AI analiz özetlerini döner, hiçbir şey kalıcı olarak saklanmaz (geçici dosya işlem sonunda silinir). google_drive_files'ı reuse eden yeni /google-drive/register-case-path endpoint'i, case_id ↔ Drive'daki local_path eşleşmesini tutan yeni case_drive_files tablosuna yazıyor (migration: laawos/sql/21_case_drive_files.sql, Supabase'de elle çalıştırılması gerekiyor). better-sqlite3 + @types/better-sqlite3 eklendi; node:22-alpine (musl) için gerekli prebuild paketle geliyor, ek derleme adımı gerekmiyor (doğrulandı). Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 33 +++++++ package.json | 2 + src/controllers/googleDrive.controller.ts | 29 ++++++ src/controllers/mobileRelay.controller.ts | 110 ++++++++++++++++++++++ src/routes/googleDrive.routes.ts | 3 +- src/routes/index.ts | 2 + src/routes/mobileRelay.routes.ts | 11 +++ 7 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/controllers/mobileRelay.controller.ts create mode 100644 src/routes/mobileRelay.routes.ts diff --git a/package-lock.json b/package-lock.json index a9b4934..db67531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@supabase/supabase-js": "^2.112.2", "adm-zip": "^0.6.0", + "better-sqlite3": "^13.0.3", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", @@ -26,6 +27,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.8", + "@types/better-sqlite3": "^9.6.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/multer": "^2.2.0", @@ -831,6 +833,16 @@ "@types/node": "*" } }, + "node_modules/@types/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1365,6 +1377,18 @@ ], "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -2319,6 +2343,15 @@ "node": ">= 0.6" } }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", diff --git a/package.json b/package.json index a55d414..af8a448 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@supabase/supabase-js": "^2.112.2", "adm-zip": "^0.6.0", + "better-sqlite3": "^13.0.3", "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", @@ -31,6 +32,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.8", + "@types/better-sqlite3": "^9.6.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/multer": "^2.2.0", diff --git a/src/controllers/googleDrive.controller.ts b/src/controllers/googleDrive.controller.ts index d70aef7..71b0a56 100644 --- a/src/controllers/googleDrive.controller.ts +++ b/src/controllers/googleDrive.controller.ts @@ -381,3 +381,32 @@ export const downloadFile = async (req: AuthenticatedRequest, res: Response) => res.status(500).json({ error: 'Dosya indirilemedi.' }); } }; + +// Faz 4 (mobil ince-köprü) — Electron, bir dava için case_id ↔ yerel SQLite +// dosyasının Drive'daki local_path'i eşleşmesini burada kaydeder. Bunu asıl +// Drive yükleme başarısından BAĞIMSIZ olarak, dava ilk dokunulduğunda +// (localDataStore.js::upsertCaseMeta) çağırıyoruz — çünkü local_path zaten +// deterministik (sanitizeName(caseTitle) + "/case.sqlite"), yüklemenin +// tamamlanmasını beklemeye gerek yok. Mobil relay endpoint'i bu eşleşmeyi +// kullanarak google_drive_files'ta doğru dosyayı bulur. Best-effort — Electron +// bu çağrı başarısız olsa bile yerel işlemi bloklamaz (bkz. çağıran taraf). +export const registerCasePath = async (req: AuthenticatedRequest, res: Response) => { + try { + const userId = req.user?.id; + const { case_id, local_path } = req.body || {}; + if (!case_id || !local_path) { + return res.status(400).json({ error: 'case_id ve local_path zorunludur.' }); + } + const { error } = await supabase + .from('case_drive_files') + .upsert( + { user_id: userId, case_id, local_path, updated_at: new Date().toISOString() }, + { onConflict: 'user_id,case_id' } + ); + if (error) throw error; + res.json({ ok: true }); + } catch (error: any) { + console.error('[DRIVE] Register Case Path Error:', error); + res.status(500).json({ error: 'Eşleşme kaydedilemedi.' }); + } +}; diff --git a/src/controllers/mobileRelay.controller.ts b/src/controllers/mobileRelay.controller.ts new file mode 100644 index 0000000..a2e9b97 --- /dev/null +++ b/src/controllers/mobileRelay.controller.ts @@ -0,0 +1,110 @@ +// Faz 4 — Mobil İnce Köprü. Mobil (legal-mobile) artık documents/analyses'i +// doğrudan Supabase'den okumuyor (RLS ile tam extracted_text/summary_json +// erişimi vardı) — bunun yerine bu endpoint'e istek atıyor, biz kullanıcının +// Google Drive'ındaki case.sqlite'ı ANLIK olarak açıp SADECE özet niteliğindeki +// bilgiyi (belge metni HARİÇ) döneriz. Hiçbir şey burada kalıcı olarak +// SAKLANMAZ — geçici dosya işlem sonunda silinir. +import { Response } from 'express'; +import { AuthenticatedRequest } from '../middleware/auth'; +import { supabase } from '../lib/supabase'; +import { getValidAccessToken } from './googleDrive.controller'; +import * as googleDrive from '../lib/googleDriveClient'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import crypto from 'crypto'; +import Database from 'better-sqlite3'; + +const EMPTY_RESULT = { documents: [], analyses: [] }; + +export const getCaseRelaySummary = async (req: AuthenticatedRequest, res: Response) => { + const userId = req.user?.id as string; + const { caseId } = req.params; + let tmpPath: string | null = null; + + try { + if (!caseId) { + return res.status(400).json({ error: 'Geçersiz caseId' }); + } + + const { data: connection } = await supabase + .from('google_drive_connections') + .select('status') + .eq('user_id', userId) + .maybeSingle(); + if (!connection || connection.status !== 'connected') { + return res.status(409).json({ error: 'Bu dosyanın analizlerine erişmek için önce masaüstü uygulamasında Google Drive bağlantınızı kurmalısınız.' }); + } + + const { data: mapping } = await supabase + .from('case_drive_files') + .select('local_path') + .eq('user_id', userId) + .eq('case_id', caseId) + .maybeSingle(); + if (!mapping) { + // Bu dava masaüstünde henüz hiç açılmamış/senkron olmamış — hata değil, + // mobil boş durumu gösterebilsin. + return res.json(EMPTY_RESULT); + } + + const { data: fileRow } = await supabase + .from('google_drive_files') + .select('drive_file_id') + .eq('user_id', userId) + .eq('local_path', mapping.local_path) + .maybeSingle(); + if (!fileRow) { + // Eşleşme var ama Drive'a henüz hiç yüklenmemiş (ilk senkron bekleniyor). + return res.json(EMPTY_RESULT); + } + + 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 buffer = await googleDrive.downloadFile(accessToken, fileRow.drive_file_id); + + tmpPath = path.join(os.tmpdir(), `mobile-relay-${crypto.randomUUID()}.sqlite`); + fs.writeFileSync(tmpPath, buffer); + + const db = new Database(tmpPath, { readonly: true }); + try { + // extracted_text BİLEREK seçilmiyor — mobil relay sadece metadata + + // AI özetlerini görür, ham belge metnini asla. + const documents = db.prepare('SELECT id, filename, uploaded_at, file_size, category FROM documents ORDER BY uploaded_at DESC').all() as any[]; + const analyses = db.prepare('SELECT id, document_id, analysis_type, summary_json, model_used, created_at FROM analyses ORDER BY created_at DESC').all() as any[]; + + res.json({ + // extracted_text alanı mobil istemcinin DocumentRow tipiyle uyumlu + // olsun diye açıkça null olarak eklenir (yok sayılmıyor, bilinçli boş). + documents: documents.map((d) => ({ ...d, extracted_text: null })), + analyses: analyses.map((a) => ({ + id: a.id, + case_id: caseId, + document_id: a.document_id, + summary_json: safeParseJson(a.summary_json), + model_used: a.model_used, + created_at: a.created_at, + })), + }); + } finally { + db.close(); + } + } catch (error: any) { + console.error('[MobileRelay] Get Case Summary Error:', error); + res.status(500).json({ error: 'Dosya özeti alınamadı.' }); + } finally { + if (tmpPath) { + fs.unlink(tmpPath, () => {}); + } + } +}; + +function safeParseJson(s: unknown) { + if (typeof s !== 'string') return s; + try { return JSON.parse(s); } catch { return s; } +} diff --git a/src/routes/googleDrive.routes.ts b/src/routes/googleDrive.routes.ts index dc4f2e7..a649d95 100644 --- a/src/routes/googleDrive.routes.ts +++ b/src/routes/googleDrive.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import multer from 'multer'; import { requireAuth } from '../middleware/auth'; -import { connect, oauthCallback, getStatus, disconnect, refresh, uploadFile, getBackupStatus, listBackupFiles, downloadFile } from '../controllers/googleDrive.controller'; +import { connect, oauthCallback, getStatus, disconnect, refresh, uploadFile, getBackupStatus, listBackupFiles, downloadFile, registerCasePath } from '../controllers/googleDrive.controller'; const router = Router(); const upload = multer({ storage: multer.memoryStorage() }); @@ -17,5 +17,6 @@ router.post('/upload', upload.single('file'), requireAuth, uploadFile); router.get('/backup-status', requireAuth, getBackupStatus); router.get('/backup-files', requireAuth, listBackupFiles); router.get('/download', requireAuth, downloadFile); +router.post('/register-case-path', requireAuth, registerCasePath); export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 1ab373d..d6bb148 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,7 @@ import draftingRoutes from './drafting.routes'; import googleDriveRoutes from './googleDrive.routes'; import authRoutes from './auth.routes'; import whatsappRoutes from './whatsapp.routes'; +import mobileRelayRoutes from './mobileRelay.routes'; const router = Router(); @@ -23,6 +24,7 @@ router.use('/templates', templateRoutes); router.use('/drafting', draftingRoutes); router.use('/google-drive', googleDriveRoutes); router.use('/whatsapp', whatsappRoutes); // POST /api/whatsapp/message (n8n webhook, ayrı X-Webhook-Secret doğrulaması) +router.use('/mobile-relay', mobileRelayRoutes); // GET /api/mobile-relay/case/:caseId/summary (Faz 4 — legal-mobile) router.use('/', precedentRoutes); // Keeps the same paths like /api/search-precedents export default router; diff --git a/src/routes/mobileRelay.routes.ts b/src/routes/mobileRelay.routes.ts new file mode 100644 index 0000000..d463be4 --- /dev/null +++ b/src/routes/mobileRelay.routes.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/auth'; +import { getCaseRelaySummary } from '../controllers/mobileRelay.controller'; + +const router = Router(); + +// GET /api/mobile-relay/case/:caseId/summary — requireAuth zaten :caseId +// param'ını görüp dosya sahipliğini doğruluyor (bkz. middleware/auth.ts). +router.get('/case/:caseId/summary', requireAuth, getCaseRelaySummary); + +export default router;