Spaces:
Running
Running
| // Backend API để upload file lên Google Drive 2TB | |
| // Sử dụng OAuth thay vì Service Account (phù hợp với tài khoản edu) | |
| // Chạy: node server.js hoặc npm start | |
| require('dotenv').config(); | |
| // ===== DEBUG: Kiểm tra secrets ===== | |
| console.log('========================================'); | |
| console.log('FIREBASE_SERVICE_ACCOUNT:', process.env.FIREBASE_SERVICE_ACCOUNT ? 'EXISTS (length: ' + process.env.FIREBASE_SERVICE_ACCOUNT.length + ')' : 'MISSING'); | |
| console.log('SMTP_USER:', process.env.SMTP_USER || 'MISSING'); | |
| console.log('SMTP_PASS:', process.env.SMTP_PASS ? 'EXISTS' : 'MISSING'); | |
| console.log('GEMINI_API_KEYS:', process.env.GEMINI_API_KEYS ? 'EXISTS' : 'MISSING'); | |
| console.log('========================================'); | |
| // ===== END DEBUG ===== | |
| const express = require('express'); | |
| const multer = require('multer'); | |
| const { google } = require('googleapis'); | |
| const path = require('path'); | |
| const fs = require('fs'); | |
| const cors = require('cors'); | |
| const app = express(); | |
| const upload = multer({ dest: 'uploads/' }); | |
| // Middleware - CORS cho phép frontend từ Vercel và local | |
| app.use(cors({ | |
| origin: process.env.ALLOWED_ORIGINS ? | |
| process.env.ALLOWED_ORIGINS.split(',') : | |
| '*', // Cho phép tất cả khi không set (chỉ dùng khi dev) | |
| credentials: true | |
| })); | |
| app.use(express.json()); | |
| // Serve static files từ thư mục ROOT (parent directory) | |
| app.use(express.static(path.join(__dirname, '..'))); | |
| // Route cho trang chủ | |
| app.get('/', (req, res) => { | |
| res.sendFile(path.join(__dirname, '..', 'index.html')); | |
| }); | |
| // OAuth Config - Lấy từ Google Cloud Console | |
| const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || 'YOUR_CLIENT_ID'; | |
| const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || 'YOUR_CLIENT_SECRET'; | |
| const REDIRECT_URI = process.env.REDIRECT_URI || 'http://localhost/popup.html'; | |
| // Lưu refresh token của admin (tài khoản 2TB) | |
| // Trong production, nên lưu vào database hoặc file bảo mật | |
| let adminRefreshToken = null; | |
| function syncAdminRefreshToken(req) { | |
| const headerToken = req.headers['x-refresh-token']; | |
| if (headerToken && headerToken !== adminRefreshToken) { | |
| adminRefreshToken = headerToken; | |
| } | |
| } | |
| // Khởi tạo OAuth2 client | |
| const oauth2Client = new google.auth.OAuth2( | |
| GOOGLE_CLIENT_ID, | |
| GOOGLE_CLIENT_SECRET, | |
| REDIRECT_URI | |
| ); | |
| // Lấy access token từ refresh token | |
| async function getAccessToken() { | |
| if (!adminRefreshToken) { | |
| throw new Error('Chưa có refresh token. Vui lòng đăng nhập Admin trước.'); | |
| } | |
| oauth2Client.setCredentials({ | |
| refresh_token: adminRefreshToken | |
| }); | |
| const { credentials } = await oauth2Client.refreshAccessToken(); | |
| return credentials.access_token; | |
| } | |
| // API: Đổi OAuth code thành token | |
| app.post('/api/oauth/token', async (req, res) => { | |
| try { | |
| const { code, redirect_uri } = req.body; | |
| if (!code) { | |
| return res.status(400).json({ error: 'Missing authorization code' }); | |
| } | |
| const { tokens } = await oauth2Client.getToken(code); | |
| // Lưu refresh token | |
| if (tokens.refresh_token) { | |
| adminRefreshToken = tokens.refresh_token; | |
| // Trong production, nên lưu vào database | |
| console.log('Admin refresh token đã được lưu'); | |
| } | |
| res.json({ | |
| access_token: tokens.access_token, | |
| refresh_token: tokens.refresh_token, | |
| expires_in: tokens.expiry_date | |
| }); | |
| } catch (error) { | |
| console.error('OAuth token error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // API: Set refresh token trực tiếp (nếu đã có) | |
| app.post('/api/oauth/set-token', async (req, res) => { | |
| try { | |
| const { refresh_token } = req.body; | |
| if (!refresh_token) { | |
| return res.status(400).json({ error: 'Missing refresh_token' }); | |
| } | |
| adminRefreshToken = refresh_token; | |
| res.json({ success: true, message: 'Refresh token đã được lưu' }); | |
| } catch (error) { | |
| console.error('Set token error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // Tìm hoặc tạo folder | |
| async function findOrCreateFolder(name, parentId = 'root') { | |
| try { | |
| const accessToken = await getAccessToken(); | |
| const drive = google.drive({ version: 'v3', auth: oauth2Client }); | |
| const response = await drive.files.list({ | |
| q: `mimeType='application/vnd.google-apps.folder' and name='${name}' and '${parentId}' in parents and trashed=false`, | |
| fields: 'files(id, name)', | |
| }); | |
| if (response.data.files.length > 0) { | |
| return response.data.files[0]; | |
| } | |
| // Tạo folder mới | |
| const folder = await drive.files.create({ | |
| requestBody: { | |
| name: name, | |
| mimeType: 'application/vnd.google-apps.folder', | |
| parents: [parentId], | |
| }, | |
| fields: 'id, name', | |
| }); | |
| return folder.data; | |
| } catch (error) { | |
| console.error('Error in findOrCreateFolder:', error); | |
| throw error; | |
| } | |
| } | |
| // Upload file | |
| async function uploadFile(filePath, fileName, parentId) { | |
| try { | |
| const accessToken = await getAccessToken(); | |
| const drive = google.drive({ version: 'v3', auth: oauth2Client }); | |
| const fileMetadata = { | |
| name: fileName, | |
| parents: [parentId], | |
| }; | |
| const media = { | |
| mimeType: 'application/octet-stream', | |
| body: fs.createReadStream(filePath), | |
| }; | |
| const file = await drive.files.create({ | |
| requestBody: fileMetadata, | |
| media: media, | |
| fields: 'id, name, webViewLink, webContentLink, iconLink', | |
| }); | |
| return file.data; | |
| } catch (error) { | |
| console.error('Error uploading file:', error); | |
| throw error; | |
| } | |
| } | |
| // API: Upload files | |
| app.post('/api/upload', upload.array('files'), async (req, res) => { | |
| try { | |
| syncAdminRefreshToken(req); | |
| const { docName, type, month } = req.body; // type: 'incoming' hoặc 'outgoing' | |
| if (!req.files || req.files.length === 0) { | |
| return res.status(400).json({ error: 'No files uploaded' }); | |
| } | |
| if (!adminRefreshToken) { | |
| return res.status(401).json({ error: 'Chưa đăng nhập Admin. Vui lòng đăng nhập tài khoản Google 2TB trước.' }); | |
| } | |
| // Tạo cấu trúc folder | |
| const qlvbFolder = await findOrCreateFolder('QLVB-DATA'); | |
| const typeFolder = await findOrCreateFolder( | |
| type === 'incoming' ? 'VanBanDen' : 'VanBanDi', | |
| qlvbFolder.id | |
| ); | |
| const monthFolder = await findOrCreateFolder(month || `Tháng ${String(new Date().getMonth() + 1).padStart(2, '0')}`, typeFolder.id); | |
| const docFolder = await findOrCreateFolder( | |
| `${docName} - ${Date.now()}`, | |
| monthFolder.id | |
| ); | |
| // Upload tất cả files | |
| const uploadedFiles = []; | |
| for (const file of req.files) { | |
| const uploadedFile = await uploadFile( | |
| file.path, | |
| file.originalname, | |
| docFolder.id | |
| ); | |
| uploadedFiles.push({ | |
| id: uploadedFile.id, | |
| name: uploadedFile.name, | |
| iconLink: `https://drive.google.com/file/d/${uploadedFile.id}/view`, | |
| webViewLink: uploadedFile.webViewLink, | |
| webContentLink: uploadedFile.webContentLink, | |
| }); | |
| // Xóa file tạm | |
| fs.unlinkSync(file.path); | |
| } | |
| // Lấy danh sách files trong folder | |
| const drive = google.drive({ version: 'v3', auth: oauth2Client }); | |
| const filesList = await drive.files.list({ | |
| q: `'${docFolder.id}' in parents and trashed=false`, | |
| fields: 'files(id, name, iconLink, webViewLink, webContentLink)', | |
| }); | |
| res.json({ | |
| folderId: docFolder.id, | |
| folderUrl: `https://drive.google.com/drive/folders/${docFolder.id}`, | |
| files: filesList.data.files || [], | |
| }); | |
| } catch (error) { | |
| console.error('Upload error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // API: Download file từ Google Drive (để tóm tắt) | |
| app.get('/api/download/:fileId', async (req, res) => { | |
| try { | |
| syncAdminRefreshToken(req); | |
| const { fileId } = req.params; | |
| if (!adminRefreshToken) { | |
| return res.status(401).json({ error: 'Chưa đăng nhập Admin' }); | |
| } | |
| await getAccessToken(); | |
| const drive = google.drive({ version: 'v3', auth: oauth2Client }); | |
| const response = await drive.files.get( | |
| { fileId, alt: 'media' }, | |
| { responseType: 'stream' } | |
| ); | |
| response.data.pipe(res); | |
| } catch (error) { | |
| console.error('Download error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // API: Kiểm tra trạng thái đăng nhập | |
| app.get('/api/oauth/status', (req, res) => { | |
| syncAdminRefreshToken(req); | |
| res.json({ | |
| logged_in: !!adminRefreshToken | |
| }); | |
| }); | |
| // ========== AI SERVICES INTEGRATION ========== | |
| const geminiService = require('./gemini-service'); | |
| const emailService = require('./email-service'); | |
| const scheduler = require('./scheduler'); | |
| const admin = require('firebase-admin'); | |
| // Initialize Firebase Admin SDK | |
| let db = null; | |
| try { | |
| if (!admin.apps.length) { | |
| // Đọc service account từ environment variable | |
| const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT; | |
| if (serviceAccountJson) { | |
| // Hugging Face Spaces - đọc từ secret | |
| const serviceAccount = JSON.parse(serviceAccountJson); | |
| admin.initializeApp({ | |
| credential: admin.credential.cert(serviceAccount) | |
| }); | |
| console.log('✓ Firebase Admin initialized from environment'); | |
| } else { | |
| // Local development - dùng default credentials | |
| admin.initializeApp({ | |
| credential: admin.credential.applicationDefault(), | |
| projectId: 'qlvb-f7f9c' | |
| }); | |
| console.log('✓ Firebase Admin initialized (default credentials)'); | |
| } | |
| } | |
| db = admin.firestore(); | |
| } catch (error) { | |
| console.warn('⚠ Firebase Admin not initialized:', error.message); | |
| } | |
| // Configure Email service | |
| if (process.env.SMTP_USER && process.env.SMTP_PASS) { | |
| emailService.configure(process.env.SMTP_USER, process.env.SMTP_PASS); | |
| console.log('✓ Email service configured'); | |
| } | |
| // Load Gemini API keys from environment | |
| if (process.env.GEMINI_API_KEYS) { | |
| const keys = process.env.GEMINI_API_KEYS.split('\n') | |
| .map(k => k.trim()) | |
| .filter(k => k && !k.startsWith('#')); | |
| if (keys.length > 0) { | |
| geminiService.setApiKeys(keys); | |
| console.log(`✓ Loaded ${keys.length} Gemini API keys from environment`); | |
| } | |
| } | |
| // ========== NEW API ENDPOINTS ========== | |
| // API: Set Gemini API keys from array | |
| app.post('/api/ai/set-keys', async (req, res) => { | |
| try { | |
| const { keys } = req.body; | |
| if (!keys || !Array.isArray(keys)) { | |
| return res.status(400).json({ error: 'Keys phải là mảng' }); | |
| } | |
| geminiService.setApiKeys(keys); | |
| res.json({ success: true, message: `Đã load ${keys.length} API keys` }); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // API: Test Gemini connection | |
| app.post('/api/ai/test-connection', async (req, res) => { | |
| try { | |
| const { apiKey } = req.body; | |
| if (!apiKey) { | |
| return res.status(400).json({ error: 'Thiếu API key' }); | |
| } | |
| const result = await geminiService.testConnection(apiKey); | |
| res.json(result); | |
| } catch (error) { | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // API: Analyze PDF and extract deadline | |
| app.post('/api/ai/analyze-pdf', async (req, res) => { | |
| try { | |
| const { fileId, modelName } = req.body; | |
| if (!fileId) { | |
| return res.status(400).json({ error: 'Thiếu fileId' }); | |
| } | |
| if (!adminRefreshToken) { | |
| return res.status(401).json({ error: 'Chưa đăng nhập Admin' }); | |
| } | |
| // Download PDF from Google Drive | |
| const pdfBase64 = await geminiService.downloadPdfFromDrive(fileId, oauth2Client); | |
| // Analyze with Gemini | |
| const result = await geminiService.analyzePdfDocument(pdfBase64, modelName || 'gemini-2.5-flash'); | |
| res.json({ | |
| success: true, | |
| deadline: result.deadline, | |
| extractedText: result.extractedText, | |
| confidence: result.confidence | |
| }); | |
| } catch (error) { | |
| console.error('PDF analysis error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // API: Get deadlines from Firestore (upcoming within X days) | |
| app.get('/api/deadlines/check', async (req, res) => { | |
| try { | |
| if (!db) { | |
| return res.status(503).json({ error: 'Firestore chưa được khởi tạo' }); | |
| } | |
| const daysAhead = parseInt(req.query.daysAhead) || 7; | |
| const now = new Date(); | |
| const futureDate = new Date(); | |
| futureDate.setDate(futureDate.getDate() + daysAhead); | |
| const deadlinesRef = db.collection('deadlines'); | |
| const snapshot = await deadlinesRef | |
| .where('deadline', '<=', futureDate) | |
| .where('reminderSent', '==', false) | |
| .get(); | |
| const deadlines = []; | |
| snapshot.forEach(doc => { | |
| const data = doc.data(); | |
| const deadline = data.deadline.toDate(); | |
| const daysLeft = Math.ceil((deadline - now) / (1000 * 60 * 60 * 24)); | |
| deadlines.push({ | |
| id: doc.id, | |
| ...data, | |
| deadline: deadline, | |
| daysLeft: daysLeft | |
| }); | |
| }); | |
| res.json({ deadlines }); | |
| } catch (error) { | |
| console.error('Check deadlines error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // Hugging Face Spaces dùng port 7860, local dùng 3000 | |
| const PORT = process.env.PORT || process.env.SPACE_PORT || 3000; | |
| // API: Send reminder emails | |
| app.post('/api/deadlines/send-reminders', async (req, res) => { | |
| try { | |
| if (!db) { | |
| return res.status(503).json({ error: 'Firestore chưa được khởi tạo' }); | |
| } | |
| // Get deadlines within 7 days | |
| const checkResponse = await fetch(`http://localhost:${PORT}/api/deadlines/check?daysAhead=7`); | |
| const { deadlines } = await checkResponse.json(); | |
| const results = []; | |
| for (const deadline of deadlines) { | |
| try { | |
| const reminderData = { | |
| documentName: deadline.documentName, | |
| documentNumber: deadline.documentNumber, | |
| deadline: deadline.deadline, | |
| daysLeft: deadline.daysLeft, | |
| driveLink: deadline.driveLink || null | |
| }; | |
| // Send to hoangthiencm@gmail.com + user email | |
| const recipients = [ | |
| 'hoangthiencm@gmail.com', | |
| 'hoangthien.thcstranphu@gmail.com', | |
| ]; | |
| if (deadline.userEmail && !recipients.includes(deadline.userEmail)) { | |
| recipients.push(deadline.userEmail); | |
| } | |
| const emailResult = await emailService.sendReminderEmail(reminderData, recipients); | |
| // Mark as sent in Firestore | |
| await db.collection('deadlines').doc(deadline.id).update({ | |
| reminderSent: true, | |
| reminderSentAt: admin.firestore.FieldValue.serverTimestamp() | |
| }); | |
| results.push({ | |
| deadlineId: deadline.id, | |
| success: true, | |
| recipients: emailResult.recipients | |
| }); | |
| } catch (error) { | |
| results.push({ | |
| deadlineId: deadline.id, | |
| success: false, | |
| error: error.message | |
| }); | |
| } | |
| } | |
| res.json({ results, total: deadlines.length }); | |
| } catch (error) { | |
| console.error('Send reminders error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // API: Check report_date reminders from van_ban collection (7-day alert) | |
| app.post('/api/reminders/check', async (req, res) => { | |
| try { | |
| if (!db) { | |
| return res.status(503).json({ error: 'Firestore chưa được khởi tạo' }); | |
| } | |
| const RECIPIENT = 'hoangthien.thcstranphu@gmail.com'; | |
| const now = new Date(); | |
| now.setHours(0, 0, 0, 0); | |
| const sevenDaysLater = new Date(now); | |
| sevenDaysLater.setDate(now.getDate() + 7); | |
| // Lấy tất cả văn bản | |
| const snapshot = await db.collection('van_ban').get(); | |
| const toSend = []; | |
| snapshot.forEach(doc => { | |
| const data = doc.data(); | |
| if (!data.report_dates || !Array.isArray(data.report_dates) || data.report_dates.length === 0) return; | |
| data.report_dates.forEach(dateStr => { | |
| if (!dateStr) return; | |
| const reportDate = new Date(dateStr); | |
| reportDate.setHours(0, 0, 0, 0); | |
| const daysLeft = Math.ceil((reportDate - now) / (1000 * 60 * 60 * 24)); | |
| // Chỉ gửi khi còn đúng 7 ngày | |
| if (daysLeft === 7) { | |
| toSend.push({ | |
| id: doc.id, | |
| name: data.name, | |
| number: data.number, | |
| report_type: data.doc_type, | |
| reportDateStr: dateStr, | |
| daysLeft | |
| }); | |
| } | |
| }); | |
| }); | |
| const results = []; | |
| for (const doc of toSend) { | |
| try { | |
| const reportDateFormatted = doc.reportDateStr.split('-').reverse().join('/'); | |
| const subject = `🔔 Nhắc nhở: Còn 7 ngày đến hạn báo cáo - ${doc.name}`; | |
| const htmlBody = ` | |
| <!DOCTYPE html><html><head><meta charset="UTF-8"> | |
| <style> | |
| body { font-family: Arial, sans-serif; color: #333; line-height: 1.6; } | |
| .container { max-width: 600px; margin: 0 auto; padding: 20px; } | |
| .header { background: linear-gradient(135deg, #f59e0b, #d97706); color: white; padding: 20px; border-radius: 8px 8px 0 0; } | |
| .content { background: #fffbeb; padding: 24px; border-radius: 0 0 8px 8px; border: 1px solid #fde68a; } | |
| .info-box { background: white; border-left: 4px solid #f59e0b; padding: 16px; margin: 16px 0; border-radius: 4px; } | |
| .label { font-weight: 600; color: #666; min-width: 140px; display: inline-block; } | |
| .footer { text-align: center; color: #888; font-size: 12px; margin-top: 20px; } | |
| </style></head><body> | |
| <div class="container"> | |
| <div class="header"><h2 style="margin:0">🔔 Nhắc nhở Báo cáo</h2> | |
| <p style="margin:5px 0 0 0;opacity:.9">Hệ thống QLVB - THCS Trần Phú</p></div> | |
| <div class="content"> | |
| <p>Xin chào,</p> | |
| <p>Văn bản sau đây sẽ <strong style="color:#d97706">đến hạn báo cáo trong 7 ngày nữa</strong>:</p> | |
| <div class="info-box"> | |
| <div><span class="label">📄 Tên văn bản:</span> ${doc.name}</div> | |
| <div><span class="label">🔢 Số/Ký hiệu:</span> ${doc.number || 'Không có'}</div> | |
| <div><span class="label">🏷️ Loại báo cáo:</span> ${doc.report_type || 'Không xác định'}</div> | |
| <div><span class="label">📅 Ngày báo cáo:</span> <strong>${reportDateFormatted}</strong></div> | |
| <div><span class="label">⏰ Còn lại:</span> <strong style="color:#d97706">7 ngày</strong></div> | |
| </div> | |
| <p><strong>Vui lòng chuẩn bị và hoàn thành báo cáo đúng hạn.</strong></p> | |
| <p style="font-size:13px;color:#666">💡 <em>Email tự động từ hệ thống QLVB. Vui lòng không trả lời.</em></p> | |
| </div> | |
| <div class="footer">© 2026 THCS Trần Phú - Hệ thống Quản lý Văn bản Điện tử</div> | |
| </div></body></html>`; | |
| await emailService.sendEmail(RECIPIENT, subject, htmlBody); | |
| results.push({ docId: doc.id, success: true }); | |
| } catch (err) { | |
| results.push({ docId: doc.id, success: false, error: err.message }); | |
| console.error('Reminder email error for doc', doc.id, err.message); | |
| } | |
| } | |
| res.json({ checked: snapshot.size, sent: toSend.length, results }); | |
| } catch (error) { | |
| console.error('Reminders check error:', error); | |
| res.status(500).json({ error: error.message }); | |
| } | |
| }); | |
| // Start daily scheduler | |
| if (process.env.ENABLE_SCHEDULER !== 'false') { | |
| scheduler.startDailyReminders(async () => { | |
| // Trigger both old deadline reminders and new report_date reminders | |
| try { | |
| await fetch(`http://localhost:${PORT}/api/deadlines/send-reminders`, { method: 'POST' }); | |
| await fetch(`http://localhost:${PORT}/api/reminders/check`, { method: 'POST' }); | |
| console.log('Daily reminder check completed'); | |
| } catch (error) { | |
| console.error('Scheduler error:', error); | |
| } | |
| }); | |
| } | |
| // Start server | |
| app.listen(PORT, () => { | |
| console.log(`Backend server running on port ${PORT}`); | |
| console.log('Lưu ý: Cần đăng nhập Admin (tài khoản 2TB) trước khi upload file'); | |
| }); | |