feat: mobil için Google Drive ince-köprü endpoint'i ekle (Faz 4)

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 <noreply@anthropic.com>
This commit is contained in:
Mustafa Yildiz
2026-08-20 00:44:12 +03:00
co-authored by Claude Sonnet 5
parent ab20d88422
commit 746848606a
7 changed files with 189 additions and 1 deletions
+33
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.112.2", "@supabase/supabase-js": "^2.112.2",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"better-sqlite3": "^13.0.3",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
@@ -26,6 +27,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.8", "@types/adm-zip": "^0.5.8",
"@types/better-sqlite3": "^9.6.0",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
@@ -831,6 +833,16 @@
"@types/node": "*" "@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": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -1365,6 +1377,18 @@
], ],
"license": "MIT" "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": { "node_modules/bluebird": {
"version": "3.4.7", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
@@ -2319,6 +2343,15 @@
"node": ">= 0.6" "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": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+2
View File
@@ -16,6 +16,7 @@
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.112.2", "@supabase/supabase-js": "^2.112.2",
"adm-zip": "^0.6.0", "adm-zip": "^0.6.0",
"better-sqlite3": "^13.0.3",
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
@@ -31,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.8", "@types/adm-zip": "^0.5.8",
"@types/better-sqlite3": "^9.6.0",
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/multer": "^2.2.0", "@types/multer": "^2.2.0",
+29
View File
@@ -381,3 +381,32 @@ export const downloadFile = async (req: AuthenticatedRequest, res: Response) =>
res.status(500).json({ error: 'Dosya indirilemedi.' }); 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.' });
}
};
+110
View File
@@ -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; }
}
+2 -1
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import multer from 'multer'; import multer from 'multer';
import { requireAuth } from '../middleware/auth'; 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 router = Router();
const upload = multer({ storage: multer.memoryStorage() }); 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-status', requireAuth, getBackupStatus);
router.get('/backup-files', requireAuth, listBackupFiles); router.get('/backup-files', requireAuth, listBackupFiles);
router.get('/download', requireAuth, downloadFile); router.get('/download', requireAuth, downloadFile);
router.post('/register-case-path', requireAuth, registerCasePath);
export default router; export default router;
+2
View File
@@ -8,6 +8,7 @@ import draftingRoutes from './drafting.routes';
import googleDriveRoutes from './googleDrive.routes'; import googleDriveRoutes from './googleDrive.routes';
import authRoutes from './auth.routes'; import authRoutes from './auth.routes';
import whatsappRoutes from './whatsapp.routes'; import whatsappRoutes from './whatsapp.routes';
import mobileRelayRoutes from './mobileRelay.routes';
const router = Router(); const router = Router();
@@ -23,6 +24,7 @@ router.use('/templates', templateRoutes);
router.use('/drafting', draftingRoutes); router.use('/drafting', draftingRoutes);
router.use('/google-drive', googleDriveRoutes); router.use('/google-drive', googleDriveRoutes);
router.use('/whatsapp', whatsappRoutes); // POST /api/whatsapp/message (n8n webhook, ayrı X-Webhook-Secret doğrulaması) 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 router.use('/', precedentRoutes); // Keeps the same paths like /api/search-precedents
export default router; export default router;
+11
View File
@@ -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;