const db = require('../config/db'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { execFile } = require('child_process'); const util = require('util'); const execFileAsync = util.promisify(execFile); const { createNotification } = require('./notificationController'); const sharp = require('sharp'); const emailService = require('../services/emailService'); const pdfService = require('../services/pdfService'); // ── Helper: insert one row into activity_log ───────────────────────────────── const logActivity = (userId, action, entityType, entityId, description) => { try { db.prepare( 'INSERT INTO activity_log (user_id, action, entity_type, entity_id, description) VALUES (?, ?, ?, ?, ?)' ).run(userId ?? null, action, entityType, entityId ?? null, description); } catch (err) { console.error('logActivity error:', err.message); } }; // ── Helper: format file size in human-readable string ───────────────────────── const formatBytes = (bytes) => { if (bytes === null || bytes === undefined || isNaN(bytes) || bytes < 0) return 'Unknown'; if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; }; const getDocSize = (d) => { if (d.file_size !== null && d.file_size !== undefined && d.file_size > 0) { return d.file_size; } if (d.file_path && fs.existsSync(d.file_path)) { try { const stat = fs.statSync(d.file_path); return stat.size; } catch (_) {} } return null; }; const STUDENT_DIR_ROOT = process.env.STUDENT_DIR_ROOT ? path.resolve(process.env.STUDENT_DIR_ROOT) : path.resolve(__dirname, '../../../data/students'); const sanitizeFolderName = (str) => (str || '').replace(/[<>:"/\\|?*\x00-\x1F]/g, '').trim(); // Configure Multer const storage = multer.diskStorage({ destination: (req, file, cb) => { // We expect studentId, documentType, and requirementId in req.body const { studentId, documentType, requirementId } = req.body; let uploadPath = process.env.UPLOAD_PATH ? path.resolve(process.env.UPLOAD_PATH) : path.resolve(__dirname, '../../../data/uploads'); if (studentId) { try { const student = db.prepare('SELECT lrn, first_name, last_name FROM students WHERE id = ?').get(studentId); if (student) { const folderName = `${sanitizeFolderName(student.last_name)}_${sanitizeFolderName(student.first_name)}_${student.lrn}`; uploadPath = path.join(STUDENT_DIR_ROOT, folderName); // Determine subfolder: 'JHS Documents', 'SHS Documents', or fallback to documentType let subFolder = documentType ? sanitizeFolderName(documentType) : 'Documents'; if (requirementId && requirementId !== 'null') { try { const reqRow = db.prepare('SELECT category FROM document_requirements WHERE id = ?').get(requirementId); if (reqRow?.category === 'JHS') subFolder = 'JHS Documents'; else if (reqRow?.category === 'SHS') subFolder = 'SHS Documents'; } catch (reqErr) { console.error('Error fetching requirement category:', reqErr); } } uploadPath = path.join(uploadPath, subFolder); } } catch (err) { console.error('Error fetching student for upload path:', err); } } if (!fs.existsSync(uploadPath)) { fs.mkdirSync(uploadPath, { recursive: true }); } // Store for use by filename callback (collision detection) req._uploadPath = uploadPath; cb(null, uploadPath); }, filename: (req, file, cb) => { // Use the document type name as the filename (originalname is set to the doc type by the frontend). // Add a human-readable timestamp prefix only if a file with the same name already exists (collision guard). const ext = path.extname(file.originalname); const base = sanitizeFolderName(path.basename(file.originalname, ext)); const desiredName = `${base}${ext}`; const destDir = req._uploadPath; if (destDir && fs.existsSync(path.join(destDir, desiredName))) { // File already exists — prefix with readable timestamp e.g. "2026-07-25_00-45-23" const now = new Date(); const pad = (n) => String(n).padStart(2, '0'); const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`; cb(null, `${ts}-${desiredName}`); } else { cb(null, desiredName); } } }); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limit fileFilter: (req, file, cb) => { // Only allow certain file types const allowedMimeTypes = [ 'application/pdf', 'image/jpeg', 'image/png', 'image/jpg', 'application/msword', // .doc 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx 'application/vnd.ms-excel', // .xls 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' // .xlsx ]; if (allowedMimeTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Invalid file format. Supported: PDF, JPG, PNG, DOC/DOCX, XLS/XLSX.'), false); } } }); exports.uploadMiddleware = (req, res, next) => { upload.single('document')(req, res, (err) => { if (err) { return res.status(400).json({ message: err.message }); } next(); }); }; exports.uploadDocument = (req, res) => { const { studentId, requirementId, documentType } = req.body; const file = req.file; if (!file) return res.status(400).json({ message: 'No file uploaded' }); if (!studentId) return res.status(400).json({ message: 'Student ID is required' }); if (!documentType) return res.status(400).json({ message: 'Document Type is required' }); const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(studentId, req.user.id); if (!hasAccess) { if (file.path && fs.existsSync(file.path)) { fs.unlinkSync(file.path); } return res.status(403).json({ message: 'Access denied to upload documents for this student.' }); } } try { const reqId = requirementId && requirementId !== 'null' ? requirementId : null; // Dynamic Upload Limit: Check how many files are allowed for this requirement let maxFiles = 1; let reqRow = null; if (reqId) { reqRow = db.prepare('SELECT id, name, max_files FROM document_requirements WHERE id = ?').get(reqId); } if (!reqRow && documentType) { reqRow = db.prepare(` SELECT id, name, max_files FROM document_requirements WHERE name = ? OR (category || ' - ' || name) = ? LIMIT 1 `).get(documentType, documentType); } if (reqRow && reqRow.max_files) { const parsed = parseInt(reqRow.max_files, 10); if (!isNaN(parsed) && parsed >= 1) maxFiles = parsed; } // Count existing non-deleted documents for this student matching requirement or document_type const existingCountRow = reqId ? db.prepare(` SELECT COUNT(*) as count FROM documents WHERE student_id = ? AND deleted_at IS NULL AND (requirement_id = ? OR document_type = ?) `).get(studentId, reqId, documentType) : db.prepare(` SELECT COUNT(*) as count FROM documents WHERE student_id = ? AND deleted_at IS NULL AND document_type = ? `).get(studentId, documentType); const currentCount = existingCountRow ? existingCountRow.count : 0; if (currentCount >= maxFiles) { if (file.path && fs.existsSync(file.path)) { try { fs.unlinkSync(file.path); } catch (_) {} } return res.status(400).json({ message: maxFiles === 1 ? `A document of type "${documentType}" already exists for this student. Duplicate uploads of the same document type are not allowed.` : `Requirement "${documentType}" allows a maximum of ${maxFiles} file(s). This student already has ${currentCount} file(s) uploaded.` }); } const fileSize = file.size || (file.path && fs.existsSync(file.path) ? fs.statSync(file.path).size : null); const result = db.prepare(` INSERT INTO documents (student_id, requirement_id, file_name, file_path, document_type, status, uploaded_by, file_size, template_id, template_version_id) VALUES (?, ?, ?, ?, ?, 'Completed', ?, ?, ?, ?) `).run( studentId, reqId, file.originalname, file.path, documentType, req.user.id, fileSize, req.body.templateId ?? null, req.body.templateVersionId ?? null ); const docId = result.lastInsertRowid; // Auto-seed Version 1 try { db.prepare(` INSERT INTO document_versions (document_id, version_number, file_name, file_path, file_size, template_id, template_version_id, uploaded_by) VALUES (?, 1, ?, ?, ?, ?, ?, ?) `).run( docId, file.originalname, file.path, fileSize, req.body.templateId ?? null, req.body.templateVersionId ?? null, req.user.id ); } catch (verErr) { console.error('[uploadDocument] Failed to seed document_versions:', verErr.message); } // Log activity const student = db.prepare('SELECT first_name, last_name FROM students WHERE id = ?').get(studentId); const studentName = student ? `${student.first_name} ${student.last_name}` : `Student #${studentId}`; logActivity(req.user.id, 'CREATE', 'document', docId, `Uploaded "${file.originalname}" (${documentType}) for ${studentName}`); createNotification(null, 'Document Uploaded', `Document "${file.originalname}" (${documentType}) has been uploaded for ${studentName}.`, 'document', 'document', docId); // Check auto-enrollment update if document is SF10 or SF9 try { const sfEnrollmentService = require('../services/sfEnrollmentService'); sfEnrollmentService.autoEnrollFromSF({ studentId, file: { path: file.path, originalname: file.originalname, mimetype: file.mimetype }, documentType, userId: req.user?.id, manual: false }).catch(e => console.error('[uploadDocument] autoEnrollFromSF async error:', e.message)); } catch (sfErr) { console.error('[uploadDocument] autoEnrollFromSF trigger error:', sfErr.message); } res.status(201).json({ id: docId, message: 'Document uploaded successfully' }); } catch (error) { console.error('Upload Error:', error); res.status(500).json({ message: 'Failed to upload document', error: error.message }); } }; exports.viewDocument = (req, res) => { try { const doc = db.prepare('SELECT student_id, file_path, file_name FROM documents WHERE id = ?').get(req.params.id); if (!doc) return res.status(404).json({ message: 'Document not found' }); const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(doc.student_id, req.user.id); if (!hasAccess) { return res.status(403).json({ message: 'Access denied to this document.' }); } } if (!fs.existsSync(doc.file_path)) { return res.status(404).json({ message: 'File not found on server' }); } if (req.query.download === 'true') { res.download(path.resolve(doc.file_path), doc.file_name); } else { res.sendFile(path.resolve(doc.file_path)); } } catch (error) { console.error('viewDocument error:', error); res.status(500).json({ message: 'Failed to view document', error: error.message }); } }; exports.getThumbnail = async (req, res) => { try { const doc = db.prepare('SELECT id, student_id, file_path, file_name FROM documents WHERE id = ?').get(req.params.id); if (!doc) return res.status(404).json({ message: 'Document not found' }); const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(doc.student_id, req.user.id); if (!hasAccess) { return res.status(403).json({ message: 'Access denied to this document.' }); } } if (!fs.existsSync(doc.file_path)) { return res.status(404).json({ message: 'File not found on server' }); } const ext = path.extname(doc.file_name || doc.file_path).toLowerCase(); const imageExtensions = ['.jpg', '.jpeg', '.png', '.webp', '.bmp', '.gif']; const thumbnailDir = path.resolve('./uploads/thumbnails'); if (!fs.existsSync(thumbnailDir)) { fs.mkdirSync(thumbnailDir, { recursive: true }); } if (imageExtensions.includes(ext)) { const thumbnailPath = path.join(thumbnailDir, `thumb_img_${doc.id}.webp`); if (fs.existsSync(thumbnailPath)) { res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); return res.sendFile(thumbnailPath); } try { await sharp(path.resolve(doc.file_path)) .resize(300, 300, { fit: 'inside', withoutEnlargement: true }) .webp({ quality: 80 }) .toFile(thumbnailPath); if (fs.existsSync(thumbnailPath)) { res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); return res.sendFile(thumbnailPath); } } catch (sharpErr) { console.warn(`[getThumbnail] Sharp image resize failed for doc ${doc.id}:`, sharpErr.message); } res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); return res.sendFile(path.resolve(doc.file_path)); } if (ext === '.pdf') { const thumbnailPath = path.join(thumbnailDir, `thumb_${doc.id}.png`); if (fs.existsSync(thumbnailPath)) { res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); return res.sendFile(thumbnailPath); } // Generate page 1 thumbnail using Ghostscript at 72 dpi const gsArgs = [ '-dQUIET', '-dPARANOIDSAFER', '-dBATCH', '-dNOPAUSE', '-dNOPROMPT', '-sDEVICE=png16m', '-dTextAlphaBits=4', '-dGraphicsAlphaBits=4', '-r72', '-dFirstPage=1', '-dLastPage=1', `-sOutputFile=${thumbnailPath}`, path.resolve(doc.file_path) ]; const isWindows = process.platform === 'win32'; try { if (isWindows) { try { await execFileAsync('gswin64c', gsArgs); } catch (err) { if (err.code === 'ENOENT') { try { await execFileAsync('gs', gsArgs); } catch (fallbackErr) { await execFileAsync('gswin32c', gsArgs); } } else throw err; } } else { await execFileAsync('gs', gsArgs); } if (fs.existsSync(thumbnailPath)) { res.setHeader('Cache-Control', 'public, max-age=86400, immutable'); return res.sendFile(thumbnailPath); } } catch (err) { console.error('[getThumbnail] Ghostscript thumbnail generation failed:', err.message); } } return res.status(404).json({ message: 'No thumbnail available for this file type' }); } catch (error) { console.error('getThumbnail error:', error); res.status(500).json({ message: 'Failed to get thumbnail', error: error.message }); } }; exports.getAllDocuments = (req, res) => { const { search = '', page = 1, limit = 20, status = '', documentType = '', gradeLevel = '', schoolYear = '', studentId = '' } = req.query; const pageNum = Math.max(1, parseInt(page)); const limitNum = Math.min(100, Math.max(1, parseInt(limit))); const offset = (pageNum - 1) * limitNum; try { const conditions = []; const params = []; if (search.trim()) { const like = `%${search.trim().split('').join('%' )}%`; conditions.push(`(s.lrn LIKE ? OR s.first_name LIKE ? OR s.last_name LIKE ? OR d.file_name LIKE ?)`); params.push(like, like, like, like); } if (status.trim() && status !== 'All Statuses') { conditions.push(`d.status = ?`); params.push(status.trim()); } else { // Default on active Documents screen: hide archived files conditions.push(`d.status = 'Completed'`); } if (documentType.trim() && documentType !== 'All Types') { const types = documentType.split(',').map(t => t.trim()).filter(t => t); if (types.length > 0) { const typeConditions = []; for (const t of types) { if (t === 'All JHS' || t === 'All JHS Requirements') { typeConditions.push(`dr.category = 'JHS'`); } else if (t === 'All SHS' || t === 'All SHS Requirements') { typeConditions.push(`dr.category = 'SHS'`); } else { const cleanT = t.replace(/^(JHS|SHS)\s*-\s*/i, '').trim(); typeConditions.push(`( d.document_type = ? OR dr.name = ? OR d.document_type = ? OR d.document_type = ? OR dr.name = ? )`); params.push(t, t, `JHS - ${cleanT}`, `SHS - ${cleanT}`, cleanT); } } conditions.push(`(${typeConditions.join(' OR ')})`); } } if (gradeLevel.trim()) { const gradeMatch = gradeLevel.trim().match(/\d+/); if (gradeMatch) { conditions.push(`e.grade_level = ?`); params.push(parseInt(gradeMatch[0])); } } if (schoolYear.trim()) { conditions.push(`ay.year_range = ?`); params.push(schoolYear.trim()); } else { conditions.push(`LOWER(ay.status) = ?`); params.push('active'); } if (false) { conditions.push(`ay.year_range = ?`); params.push(schoolYear.trim()); } if (studentId.trim()) { conditions.push(`d.student_id = ?`); params.push(studentId.trim()); } // Only show active documents for Enrolled students conditions.push("d.deleted_at IS NULL"); conditions.push("s.status = 'Enrolled'"); const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; const teacherId = req.user?.id; const whereClause = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const teacherJoin = isTeacher ? `JOIN ( SELECT DISTINCT e_sub.student_id FROM enrollments e_sub JOIN teacher_sections ts_sub ON e_sub.section_id = ts_sub.section_id WHERE ts_sub.teacher_id = ? ) t_scope ON t_scope.student_id = d.student_id` : ''; if (isTeacher) { params.unshift(teacherId); } const joins = ` LEFT JOIN students s ON d.student_id = s.id LEFT JOIN enrollments e ON e.student_id = s.id AND e.id = ( SELECT e2.id FROM enrollments e2 JOIN academic_years ay_inner ON e2.academic_year_id = ay_inner.id WHERE e2.student_id = s.id ORDER BY ay_inner.year_range DESC, e2.grade_level DESC, e2.id DESC LIMIT 1 ) ${teacherJoin} LEFT JOIN academic_years ay ON e.academic_year_id = ay.id LEFT JOIN document_requirements dr ON d.requirement_id = dr.id `; const countSql = ` SELECT COUNT(DISTINCT d.id) as total FROM documents d ${joins} ${whereClause} `; const total = db.prepare(countSql).get(params).total; const fetchSql = ` SELECT DISTINCT d.id, d.student_id, d.requirement_id, d.file_name, d.document_type, d.status, d.created_at, d.file_path, d.uploaded_by, d.file_size, s.lrn as student_lrn, s.first_name || ' ' || s.last_name as student_name, TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) as uploaded_by_name, u.username as uploaded_by_username FROM documents d LEFT JOIN users u ON d.uploaded_by = u.id ${joins} ${whereClause} ORDER BY d.created_at DESC LIMIT ? OFFSET ? `; const documents = db.prepare(fetchSql).all([...params, limitNum, offset]); const mappedDocs = documents.map(d => { const rawSize = getDocSize(d); const uploaderName = d.uploaded_by_name && d.uploaded_by_name.trim().length > 0 ? d.uploaded_by_name.trim() : (d.uploaded_by_username || (d.uploaded_by ? `User #${d.uploaded_by}` : null)); return { id: d.id, studentId: d.student_id, requirementId: d.requirement_id, fileName: d.file_name, documentType: d.document_type, status: d.status, createdAt: d.created_at, studentLrn: d.student_lrn, studentName: d.student_name, uploadedBy: d.uploaded_by, uploadedByName: uploaderName, fileSize: rawSize, size: rawSize !== null ? formatBytes(rawSize) : 'Unknown', filePath: d.file_path }; }); res.json({ documents: mappedDocs, pagination: { total, page: pageNum, limit: limitNum, totalPages: Math.ceil(total / limitNum) } }); } catch (error) { console.error('getAllDocuments error:', error); res.status(500).json({ message: 'Failed to fetch documents', error: error.message }); } }; exports.getRequirements = (req, res) => { try { const requirements = db.prepare('SELECT * FROM document_requirements ORDER BY name ASC').all(); res.json(requirements); } catch (error) { res.status(500).json({ message: 'Failed to fetch document requirements', error: error.message }); } }; exports.getStatuses = (req, res) => { try { const rows = db.prepare( "SELECT DISTINCT status FROM documents WHERE status IS NOT NULL ORDER BY status ASC" ).all(); const statuses = rows.map(r => r.status); res.json(statuses); } catch (error) { res.status(500).json({ message: 'Failed to fetch statuses', error: error.message }); } }; exports.getDocumentTypes = (req, res) => { try { const reqRows = db.prepare(` SELECT DISTINCT name, category FROM document_requirements WHERE is_enabled = 1 ORDER BY name ASC `).all(); const docRows = db.prepare(` SELECT DISTINCT document_type FROM documents WHERE document_type IS NOT NULL AND document_type != '' ORDER BY document_type ASC `).all(); const jhsSet = new Set(); const shsSet = new Set(); const generalSet = new Set(); for (const r of reqRows) { const cleanName = r.name.replace(/^(JHS|SHS)\s*-\s*/i, '').trim(); if (r.category === 'JHS') { jhsSet.add(cleanName); } else if (r.category === 'SHS') { shsSet.add(cleanName); } else { generalSet.add(cleanName); } } for (const d of docRows) { const dt = d.document_type.trim(); if (/^JHS\s*-\s*/i.test(dt)) { jhsSet.add(dt.replace(/^JHS\s*-\s*/i, '').trim()); } else if (/^SHS\s*-\s*/i.test(dt)) { shsSet.add(dt.replace(/^SHS\s*-\s*/i, '').trim()); } else if (dt) { if (!jhsSet.has(dt) && !shsSet.has(dt)) { generalSet.add(dt); } } } res.json({ jhs: Array.from(jhsSet).sort(), shs: Array.from(shsSet).sort(), general: Array.from(generalSet).sort(), }); } catch (error) { res.status(500).json({ message: 'Failed to fetch document types', error: error.message }); } }; // ============================================================ // GET /api/documents/student/:studentId — get single student's documents // ============================================================ exports.getDocumentsByStudent = exports.getDocumentById = (req, res) => { const studentId = req.params.studentId || req.params.id; try { const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(studentId, req.user.id); if (!hasAccess) { return res.status(403).json({ message: "Access denied to fetch this student's documents." }); } } const { status } = req.query; let statusCondition = "AND d.status = 'Completed'"; const queryParams = [studentId]; if (status && status !== 'All Statuses' && status !== 'all' && status !== 'Completed') { statusCondition = "AND d.status = ?"; queryParams.push(status); } else { statusCondition = "AND d.status = 'Completed'"; } const documents = db.prepare(` SELECT d.*, dr.name as requirement_name, s.lrn as student_lrn, s.first_name || ' ' || s.last_name as student_name, TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) as uploaded_by_name, u.username as uploaded_by_username FROM documents d LEFT JOIN users u ON d.uploaded_by = u.id LEFT JOIN document_requirements dr ON d.requirement_id = dr.id LEFT JOIN students s ON d.student_id = s.id WHERE d.student_id = ? AND d.deleted_at IS NULL ${statusCondition} `).all(...queryParams); const mappedDocs = documents.map(d => { const rawSize = getDocSize(d); const uploaderName = d.uploaded_by_name && d.uploaded_by_name.trim().length > 0 ? d.uploaded_by_name.trim() : (d.uploaded_by_username || (d.uploaded_by ? `User #${d.uploaded_by}` : null)); return { id: d.id, studentId: d.student_id, requirementId: d.requirement_id, fileName: d.file_name, documentType: d.document_type, status: d.status, createdAt: d.created_at, studentLrn: d.student_lrn, studentName: d.student_name, uploadedBy: d.uploaded_by, uploadedByName: uploaderName, fileSize: rawSize, size: rawSize !== null ? formatBytes(rawSize) : 'Unknown', filePath: d.file_path, requirementName: d.requirement_name }; }); res.json(mappedDocs); } catch (error) { res.status(500).json({ message: 'Failed to fetch documents', error: error.message }); } }; exports.updateStatus = (req, res) => { const { status } = req.body; if (!['Completed', 'Archived'].includes(status)) { return res.status(400).json({ message: 'Invalid status. Must be Completed or Archived.' }); } try { const doc = db.prepare('SELECT file_name, student_id FROM documents WHERE id = ?').get(req.params.id); db.prepare('UPDATE documents SET status = ? WHERE id = ?').run(status, req.params.id); if (doc) { logActivity(req.user?.id, 'UPDATE', 'document', req.params.id, `Changed status of "${doc.file_name}" to ${status}`); } res.json({ message: 'Document status updated' }); } catch (error) { res.status(500).json({ message: 'Failed to update status', error: error.message }); } }; exports.deleteDocument = (req, res) => { try { const document = db.prepare('SELECT file_name, student_id, file_path, document_type FROM documents WHERE id = ?').get(req.params.id); if (!document) return res.status(404).json({ message: 'Document not found' }); db.transaction(() => { db.prepare(` INSERT INTO recent_deleted (document_id, student_id, file_name, file_path, document_type, deleted_by) VALUES (?, ?, ?, ?, ?, ?) `).run(req.params.id, document.student_id, document.file_name, document.file_path, document.document_type, req.user?.id); db.prepare("UPDATE documents SET deleted_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?").run(req.params.id); })(); logActivity(req.user?.id, 'DELETE', 'document', req.params.id, `Moved document "${document.file_name}" to Recycle Bin`); createNotification(null, 'Document Deleted', `Document "${document.file_name}" was moved to Recycle Bin.`, 'document', 'document', req.params.id); res.json({ message: 'Document moved to Recycle Bin successfully' }); } catch (error) { res.status(500).json({ message: 'Failed to delete document', error: error.message }); } }; // ============================================================ // GET /api/documents/print-queue — get print queue for user // ============================================================ exports.getPrintQueue = (req, res) => { try { const userId = req.user.id; const queue = db.prepare(` SELECT pq.id as queue_id, pq.document_id, pq.added_at, d.file_name, d.file_path, d.document_type, d.status, s.first_name || ' ' || s.last_name as student_name, s.lrn as student_lrn FROM print_queue pq JOIN documents d ON pq.document_id = d.id LEFT JOIN students s ON d.student_id = s.id WHERE pq.user_id = ? ORDER BY pq.added_at DESC `).all(userId); res.json(queue); } catch (error) { console.error('getPrintQueue error:', error); res.status(500).json({ message: 'Failed to fetch print queue', error: error.message }); } }; exports.getPrintHistory = (req, res) => { try { const userId = req.user.id; const history = db.prepare(` SELECT ph.id, ph.document_id, ph.document_name, ph.student_name, ph.printed_at, s.lrn as student_lrn, d.file_name, d.document_type FROM printed_document_history ph LEFT JOIN students s ON ph.student_id = s.id LEFT JOIN documents d ON ph.document_id = d.id WHERE ph.user_id = ? ORDER BY ph.printed_at DESC LIMIT 50 `).all(userId); res.json(history); } catch (error) { console.error('getPrintHistory error:', error); res.status(500).json({ message: 'Failed to fetch print history', error: error.message }); } }; exports.clearPrintHistory = (req, res) => { try { const userId = req.user.id; db.prepare('DELETE FROM printed_document_history WHERE user_id = ?').run(userId); res.json({ message: 'Print history cleared' }); } catch (error) { console.error('clearPrintHistory error:', error); res.status(500).json({ message: 'Failed to clear print history', error: error.message }); } }; // ============================================================ // POST /api/documents/print-queue — add document to print queue // ============================================================ exports.addToPrintQueue = (req, res) => { const { documentId } = req.body; const userId = req.user.id; if (!documentId) return res.status(400).json({ message: 'Document ID is required' }); try { const document = db.prepare('SELECT id, student_id FROM documents WHERE id = ?').get(documentId); if (!document) return res.status(404).json({ message: 'Document not found' }); const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(document.student_id, userId); if (!hasAccess) { return res.status(403).json({ message: 'Access denied to print this document.' }); } } // Check if already in queue for this user const existing = db.prepare('SELECT id FROM print_queue WHERE document_id = ? AND user_id = ?').get(documentId, userId); if (existing) { return res.status(409).json({ message: 'Document already in print queue' }); } const result = db.prepare('INSERT INTO print_queue (document_id, user_id) VALUES (?, ?)').run(documentId, userId); res.status(201).json({ id: result.lastInsertRowid, message: 'Added to print queue' }); } catch (error) { console.error('addToPrintQueue error:', error); res.status(500).json({ message: 'Failed to add to print queue', error: error.message }); } }; // ============================================================ // DELETE /api/documents/print-queue/:queueId — remove from print queue // ============================================================ exports.removeFromPrintQueue = (req, res) => { const { queueId } = req.params; const userId = req.user.id; try { const item = db.prepare('SELECT id FROM print_queue WHERE id = ? AND user_id = ?').get(queueId, userId); if (!item) return res.status(404).json({ message: 'Queue item not found' }); db.prepare('DELETE FROM print_queue WHERE id = ?').run(queueId); res.json({ message: 'Removed from print queue' }); } catch (error) { console.error('removeFromPrintQueue error:', error); res.status(500).json({ message: 'Failed to remove from print queue', error: error.message }); } }; // ============================================================ // DELETE /api/documents/print-queue — clear all from user's queue // ============================================================ exports.clearPrintQueue = (req, res) => { const userId = req.user.id; try { db.prepare('DELETE FROM print_queue WHERE user_id = ?').run(userId); res.json({ message: 'Print queue cleared' }); } catch (error) { console.error('clearPrintQueue error:', error); res.status(500).json({ message: 'Failed to clear print queue', error: error.message }); } }; // ============================================================ // POST /api/documents/:id/copy — copy a document // ============================================================ exports.copyDocument = (req, res) => { const { id } = req.params; try { const doc = db.prepare('SELECT * FROM documents WHERE id = ?').get(id); if (!doc) return res.status(404).json({ message: 'Document not found' }); const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(doc.student_id, req.user.id); if (!hasAccess) { return res.status(403).json({ message: 'Access denied to copy this document.' }); } } const originalPath = doc.file_path; if (!fs.existsSync(originalPath)) { return res.status(404).json({ message: 'Physical file not found' }); } const ext = path.extname(doc.file_name); const baseName = path.basename(doc.file_name, ext); const existingNames = db.prepare( 'SELECT file_name FROM documents WHERE student_id = ? AND deleted_at IS NULL' ).all(doc.student_id).map((r) => r.file_name); let counter = 1; let newFileName = `${baseName} (${counter})${ext}`; while (existingNames.includes(newFileName)) { counter++; newFileName = `${baseName} (${counter})${ext}`; } const dir = path.dirname(originalPath); const newFilePath = path.join(dir, `${Date.now()}-${newFileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`); // Copy file on disk fs.copyFileSync(originalPath, newFilePath); // Insert into DB const fileSize = doc.file_size || (fs.existsSync(newFilePath) ? fs.statSync(newFilePath).size : null); const result = db.prepare(` INSERT INTO documents (student_id, requirement_id, file_name, file_path, document_type, status, uploaded_by, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run(doc.student_id, doc.requirement_id, newFileName, newFilePath, doc.document_type, doc.status, req.user.id, fileSize); // Log activity const student = db.prepare('SELECT first_name, last_name FROM students WHERE id = ?').get(doc.student_id); const studentName = student ? `${student.first_name} ${student.last_name}` : `Student #${doc.student_id}`; logActivity(req.user.id, 'CREATE', 'document', result.lastInsertRowid, `Copied document "${doc.file_name}" as "${newFileName}" for ${studentName}`); createNotification(null, 'Document Copied', `Document "${doc.file_name}" copied as "${newFileName}" for ${studentName}.`, 'document', 'document', result.lastInsertRowid); res.status(201).json({ id: result.lastInsertRowid, fileName: newFileName, message: 'Document copied successfully' }); } catch (error) { console.error('Copy Error:', error); res.status(500).json({ message: 'Failed to copy document', error: error.message }); } }; // ============================================================ // POST /api/documents/bulk-delete — bulk delete documents // ============================================================ exports.bulkDelete = (req, res) => { const { ids } = req.body; if (!ids || !Array.isArray(ids) || !ids.length) { return res.status(400).json({ message: 'No document IDs provided' }); } try { const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; const teacherId = req.user?.id; let deletedCount = 0; const deletedNames = []; const getStmt = db.prepare('SELECT file_name, student_id, file_path, document_type FROM documents WHERE id = ?'); const insertRecentStmt = db.prepare(` INSERT INTO recent_deleted (document_id, student_id, file_name, file_path, document_type, deleted_by) VALUES (?, ?, ?, ?, ?, ?) `); const softDeleteStmt = db.prepare('UPDATE documents SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?'); const transaction = db.transaction(() => { for (const id of ids) { const doc = getStmt.get(id); if (doc) { if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(doc.student_id, teacherId); if (!hasAccess) continue; } insertRecentStmt.run(id, doc.student_id, doc.file_name, doc.file_path, doc.document_type, req.user?.id); softDeleteStmt.run(id); deletedCount++; deletedNames.push(doc.file_name); } } }); transaction(); if (deletedCount > 0) { logActivity(req.user?.id, 'DELETE', 'document', null, `Moved ${deletedCount} documents to Recycle Bin: ${deletedNames.join(', ')}`); } res.json({ message: `Successfully moved ${deletedCount} documents to recycle bin` }); } catch (error) { console.error('bulkDelete error:', error); res.status(500).json({ message: 'Failed to bulk delete documents', error: error.message }); } }; // ============================================================ // POST /api/documents/bulk-status — bulk status updates // ============================================================ exports.bulkStatus = (req, res) => { const { ids, status } = req.body; if (!ids || !Array.isArray(ids) || !ids.length) { return res.status(400).json({ message: 'No document IDs provided' }); } if (!['Completed', 'Archived'].includes(status)) { return res.status(400).json({ message: 'Invalid status. Must be Completed or Archived.' }); } try { const updatedNames = []; const updateStmt = db.prepare('UPDATE documents SET status = ? WHERE id = ?'); const getStmt = db.prepare('SELECT file_name FROM documents WHERE id = ?'); const transaction = db.transaction(() => { for (const id of ids) { const doc = getStmt.get(id); if (doc) { updateStmt.run(status, id); updatedNames.push(doc.file_name); } } }); transaction(); logActivity(req.user?.id, 'UPDATE', 'document', null, `Bulk updated status of ${updatedNames.length} documents to ${status}`); res.json({ message: `Successfully updated status of ${updatedNames.length} documents` }); } catch (error) { console.error('bulkStatus error:', error); res.status(500).json({ message: 'Failed to bulk update status', error: error.message }); } }; // ============================================================ // POST /api/documents/bulk-print — bulk add to print queue // ============================================================ exports.bulkAddToPrintQueue = (req, res) => { const { ids } = req.body; const userId = req.user.id; if (!ids || !Array.isArray(ids) || !ids.length) { return res.status(400).json({ message: 'No document IDs provided' }); } try { const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; let addedCount = 0; const checkStmt = db.prepare('SELECT id FROM print_queue WHERE document_id = ? AND user_id = ?'); const getDocStudentStmt = db.prepare('SELECT student_id FROM documents WHERE id = ?'); const insertStmt = db.prepare('INSERT INTO print_queue (document_id, user_id) VALUES (?, ?)'); const transaction = db.transaction(() => { for (const id of ids) { const existing = checkStmt.get(id, userId); if (!existing) { if (isTeacher) { const docStudent = getDocStudentStmt.get(id); if (!docStudent) continue; const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(docStudent.student_id, userId); if (!hasAccess) continue; } insertStmt.run(id, userId); addedCount++; } } }); transaction(); res.json({ message: `Successfully added ${addedCount} documents to print list` }); } catch (error) { console.error('bulkAddToPrintQueue error:', error); res.status(500).json({ message: 'Failed to bulk add to print queue', error: error.message }); } }; // ============================================================ // POST /api/documents/bulk-copy — bulk copy documents // ============================================================ exports.bulkCopy = (req, res) => { const { ids } = req.body; const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (!ids || !Array.isArray(ids) || !ids.length) { return res.status(400).json({ message: 'No document IDs provided' }); } try { let copiedCount = 0; const getStmt = db.prepare('SELECT * FROM documents WHERE id = ?'); const insertStmt = db.prepare(` INSERT INTO documents (student_id, requirement_id, file_name, file_path, document_type, status, uploaded_by, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); const usedNames = new Set(); for (const id of ids) { const doc = getStmt.get(id); if (doc) { if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(doc.student_id, req.user.id); if (!hasAccess) { continue; } } const originalPath = doc.file_path; if (fs.existsSync(originalPath)) { const ext = path.extname(doc.file_name); const baseName = path.basename(doc.file_name, ext); const existingNames = db.prepare( 'SELECT file_name FROM documents WHERE student_id = ? AND deleted_at IS NULL' ).all(doc.student_id).map((r) => r.file_name); let counter = 1; let newFileName = `${baseName} (${counter})${ext}`; while (existingNames.includes(newFileName) || usedNames.has(newFileName)) { counter++; newFileName = `${baseName} (${counter})${ext}`; } usedNames.add(newFileName); const dir = path.dirname(originalPath); const newFilePath = path.join(dir, `${Date.now()}-${newFileName.replace(/[^a-zA-Z0-9.-]/g, '_')}`); fs.copyFileSync(originalPath, newFilePath); const copiedFileSize = doc.file_size || (fs.existsSync(newFilePath) ? fs.statSync(newFilePath).size : null); insertStmt.run(doc.student_id, doc.requirement_id, newFileName, newFilePath, doc.document_type, doc.status, req.user.id, copiedFileSize); copiedCount++; } } } res.json({ message: `Successfully copied ${copiedCount} documents` }); } catch (error) { console.error('bulkCopy error:', error); res.status(500).json({ message: 'Failed to bulk copy documents', error: error.message }); } }; // ============================================================ // GET /api/documents/trash — get soft-deleted documents (Recycle Bin) // ============================================================ exports.getTrashDocuments = (req, res) => { try { const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; const teacherId = req.user?.id; const teacherJoin = isTeacher ? `JOIN ( SELECT DISTINCT e_sub.student_id FROM enrollments e_sub JOIN teacher_sections ts_sub ON e_sub.section_id = ts_sub.section_id WHERE ts_sub.teacher_id = ? ) t_scope ON t_scope.student_id = rd.student_id` : ''; const params = isTeacher ? [teacherId] : []; const rows = db.prepare(` SELECT rd.document_id as id, rd.student_id, rd.file_name, rd.document_type, rd.file_path, rd.deleted_at, s.lrn as student_lrn, s.first_name || ' ' || s.last_name as student_name, d.file_size FROM recent_deleted rd LEFT JOIN students s ON rd.student_id = s.id LEFT JOIN documents d ON rd.document_id = d.id ${teacherJoin} WHERE rd.document_id IS NOT NULL ORDER BY rd.deleted_at DESC `).all(...params); const mappedDocs = rows.map(d => { const deletedTime = new Date(d.deleted_at).getTime(); const now = Date.now(); const diffMs = now - deletedTime; const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const daysRemaining = Math.max(0, 30 - diffDays); const rawSize = d.file_size; return { id: d.id, studentId: d.student_id, fileName: d.file_name, documentType: d.document_type, status: 'Completed', createdAt: d.deleted_at, studentLrn: d.student_lrn, studentName: d.student_name, deletedAt: d.deleted_at, daysRemaining, fileSize: rawSize, size: rawSize !== null ? formatBytes(rawSize) : 'Unknown', filePath: d.file_path }; }); res.json(mappedDocs); } catch (error) { console.error('getTrashDocuments error:', error); res.status(500).json({ message: 'Failed to fetch trash documents', error: error.message }); } }; // ============================================================ // POST /api/documents/:id/restore — restore a soft-deleted document // ============================================================ exports.restoreDocument = (req, res) => { try { const doc = db.prepare('SELECT file_name FROM documents WHERE id = ?').get(req.params.id); if (!doc) return res.status(404).json({ message: 'Document not found' }); db.transaction(() => { db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?").run(req.params.id); db.prepare("DELETE FROM recent_deleted WHERE document_id = ?").run(req.params.id); })(); logActivity(req.user?.id, 'UPDATE', 'document', req.params.id, `Restored document "${doc.file_name}"`); createNotification(null, 'Document Restored', `Document "${doc.file_name}" was restored from Recycle Bin.`, 'document', 'document', req.params.id); res.json({ message: 'Document restored successfully' }); } catch (error) { console.error('restoreDocument error:', error); res.status(500).json({ message: 'Failed to restore document', error: error.message }); } }; // ============================================================ // POST /api/documents/bulk-restore — bulk restore soft-deleted documents // ============================================================ exports.bulkRestore = (req, res) => { const { ids } = req.body; if (!ids || !Array.isArray(ids) || !ids.length) { return res.status(400).json({ message: 'No document IDs provided' }); } try { const restoredNames = []; const getStmt = db.prepare('SELECT file_name FROM documents WHERE id = ?'); const updateStmt = db.prepare("UPDATE documents SET deleted_at = NULL WHERE id = ?"); const deleteTrashStmt = db.prepare("DELETE FROM recent_deleted WHERE document_id = ?"); db.transaction(() => { for (const id of ids) { const doc = getStmt.get(id); if (doc) { updateStmt.run(id); deleteTrashStmt.run(id); restoredNames.push(doc.file_name); } } })(); logActivity(req.user?.id, 'UPDATE', 'document', null, `Bulk restored ${restoredNames.length} documents: ${restoredNames.join(', ')}`); res.json({ message: `Successfully restored ${restoredNames.length} documents` }); } catch (error) { console.error('bulkRestore error:', error); res.status(500).json({ message: 'Failed to bulk restore documents', error: error.message }); } }; // ============================================================ // DELETE /api/documents/:id/permanent — permanently delete a document // ============================================================ exports.permanentDeleteDocument = (req, res) => { try { const doc = db.prepare('SELECT file_path, file_name FROM documents WHERE id = ?').get(req.params.id); if (!doc) return res.status(404).json({ message: 'Document not found' }); if (fs.existsSync(doc.file_path)) { fs.unlinkSync(doc.file_path); } const thumbnailDir = path.resolve('./uploads/thumbnails'); const imgThumb = path.join(thumbnailDir, `thumb_img_${req.params.id}.webp`); const pdfThumb = path.join(thumbnailDir, `thumb_${req.params.id}.png`); if (fs.existsSync(imgThumb)) { try { fs.unlinkSync(imgThumb); } catch (_) {} } if (fs.existsSync(pdfThumb)) { try { fs.unlinkSync(pdfThumb); } catch (_) {} } db.transaction(() => { db.prepare('DELETE FROM documents WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM recent_deleted WHERE document_id = ?').run(req.params.id); })(); logActivity(req.user?.id, 'DELETE', 'document', req.params.id, `Permanently deleted document "${doc.file_name}"`); res.json({ message: 'Document permanently deleted' }); } catch (error) { console.error('permanentDeleteDocument error:', error); res.status(500).json({ message: 'Failed to permanently delete document', error: error.message }); } }; // ============================================================ // POST /api/documents/bulk-permanent-delete — bulk permanently delete documents // ============================================================ exports.bulkPermanentDelete = (req, res) => { const { ids } = req.body; if (!ids || !Array.isArray(ids) || !ids.length) { return res.status(400).json({ message: 'No document IDs provided' }); } try { const deletedNames = []; const getStmt = db.prepare('SELECT file_path, file_name FROM documents WHERE id = ?'); const deleteStmt = db.prepare('DELETE FROM documents WHERE id = ?'); const deleteTrashStmt = db.prepare('DELETE FROM recent_deleted WHERE document_id = ?'); const thumbnailDir = path.resolve('./uploads/thumbnails'); db.transaction(() => { for (const id of ids) { const doc = getStmt.get(id); if (doc) { if (fs.existsSync(doc.file_path)) { fs.unlinkSync(doc.file_path); } const imgThumb = path.join(thumbnailDir, `thumb_img_${id}.webp`); const pdfThumb = path.join(thumbnailDir, `thumb_${id}.png`); if (fs.existsSync(imgThumb)) { try { fs.unlinkSync(imgThumb); } catch (_) {} } if (fs.existsSync(pdfThumb)) { try { fs.unlinkSync(pdfThumb); } catch (_) {} } deleteStmt.run(id); deleteTrashStmt.run(id); deletedNames.push(doc.file_name); } } })(); logActivity(req.user?.id, 'DELETE', 'document', null, `Bulk permanently deleted ${deletedNames.length} documents: ${deletedNames.join(', ')}`); res.json({ message: `Successfully permanently deleted ${deletedNames.length} documents` }); } catch (error) { console.error('bulkPermanentDelete error:', error); res.status(500).json({ message: 'Failed to bulk permanently delete documents', error: error.message }); } }; // ============================================================ // POST /api/documents/print-queue/print — execute print and log history // ============================================================ exports.executePrintQueue = (req, res) => { const userId = req.user.id; try { const queueItems = db.prepare(` SELECT pq.document_id, d.student_id, d.file_name AS document_name, (s.first_name || ' ' || s.last_name) AS student_name FROM print_queue pq JOIN documents d ON pq.document_id = d.id LEFT JOIN students s ON d.student_id = s.id WHERE pq.user_id = ? `).all(userId); if (!queueItems.length) { return res.status(400).json({ message: 'Print queue is empty' }); } const insertStmt = db.prepare(` INSERT INTO printed_document_history (document_id, student_id, user_id, document_name, student_name) VALUES (?, ?, ?, ?, ?) `); db.transaction(() => { for (const item of queueItems) { insertStmt.run( item.document_id, item.student_id, userId, item.document_name, item.student_name || 'General' ); } db.prepare('DELETE FROM print_queue WHERE user_id = ?').run(userId); })(); logActivity(userId, 'CREATE', 'printed_history', null, `Executed batch print for ${queueItems.length} documents and cleared print list`); res.json({ message: `Sent ${queueItems.length} documents to printed history log and cleared queue` }); } catch (error) { console.error('executePrintQueue error:', error); res.status(500).json({ message: 'Failed to execute print queue', error: error.message }); } }; // POST /api/documents/print-queue/merge-pdf exports.mergePrintQueuePdf = async (req, res) => { try { const userId = req.user.id; const { documentIds } = req.body || {}; const pdfBuffer = await pdfService.mergeDocumentsToPdf({ documentIds, userId }); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', 'inline; filename="merged_documents.pdf"'); res.send(pdfBuffer); } catch (error) { console.error('mergePrintQueuePdf error:', error); res.status(500).json({ message: 'Failed to merge documents into PDF', error: error.message }); } }; // ============================================================ // Expired Soft Delete Cleanup (30 days) // ============================================================ const cleanupExpiredDeletedDocuments = () => { try { const expired = db.prepare(` SELECT id, document_id, file_path, file_name FROM recent_deleted WHERE datetime(deleted_at) < datetime('now', '-30 days') `).all(); if (expired.length > 0) { console.log(`[Auto Cleanup] Found ${expired.length} expired deleted documents in recent_deleted. Permanently deleting...`); const deleteStmt = db.prepare('DELETE FROM documents WHERE id = ?'); const deleteTrashStmt = db.prepare('DELETE FROM recent_deleted WHERE id = ?'); db.transaction(() => { for (const doc of expired) { if (fs.existsSync(doc.file_path)) { fs.unlinkSync(doc.file_path); } deleteStmt.run(doc.document_id); deleteTrashStmt.run(doc.id); console.log(`[Auto Cleanup] Permanently deleted "${doc.file_name}"`); } })(); } } catch (err) { console.error('[Auto Cleanup] Error during document cleanup:', err.message); } }; // Run immediately on backend start, then every 24 hours cleanupExpiredDeletedDocuments(); setInterval(cleanupExpiredDeletedDocuments, 24 * 60 * 60 * 1000); // ============================================================ // POST /api/documents/print-notify-email — send pickup email // ============================================================ exports.sendPrintPickupNotification = async (req, res) => { try { const { email, studentName, documentNames, pickupDate, message } = req.body; if (!email || typeof email !== 'string' || !email.includes('@')) { return res.status(400).json({ message: 'A valid student recipient email address is required.' }); } const result = await emailService.sendDocumentPickupEmail({ to: email.trim(), studentName: studentName || 'Student', documentNames: Array.isArray(documentNames) ? documentNames : ['Requested Document'], pickupDate: pickupDate || 'Next School Day', message: message ? message.trim() : null, }); logActivity( req.user.id, 'NOTIFY', 'document', null, `Sent document pickup notification email to ${email.trim()} for ${studentName || 'Student'}` ); res.json({ message: 'Pickup notification email sent successfully', details: result, }); } catch (error) { console.error('sendPrintPickupNotification error:', error); res.status(500).json({ message: 'Failed to send pickup notification email', error: error.message }); } }; // ============================================================ // GET /api/documents/:id/versions — list all versions // ============================================================ exports.getDocumentVersions = (req, res) => { try { const doc = db.prepare('SELECT id, student_id FROM documents WHERE id = ? AND deleted_at IS NULL').get(req.params.id); if (!doc) return res.status(404).json({ message: 'Document not found' }); const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(doc.student_id, req.user.id); if (!hasAccess) return res.status(403).json({ message: 'Access denied.' }); } const versions = db.prepare(` SELECT dv.id, dv.version_number, dv.file_name, dv.file_size, dv.notes, dv.uploaded_by, dv.created_at, u.first_name || ' ' || u.last_name AS uploaded_by_name, dv.template_id, dv.template_version_id, tv.version_number AS template_version_number, dt.name AS template_name FROM document_versions dv LEFT JOIN users u ON dv.uploaded_by = u.id LEFT JOIN template_versions tv ON dv.template_version_id = tv.id LEFT JOIN document_templates dt ON dv.template_id = dt.id WHERE dv.document_id = ? ORDER BY dv.version_number DESC `).all(req.params.id); res.json(versions); } catch (error) { console.error('getDocumentVersions error:', error); res.status(500).json({ message: 'Failed to fetch document versions', error: error.message }); } }; // ============================================================ // POST /api/documents/:id/upload-version — upload a new version // ============================================================ exports.uploadDocumentVersion = (req, res) => { const file = req.file; if (!file) return res.status(400).json({ message: 'No file uploaded' }); try { const doc = db.prepare('SELECT id, student_id, document_type, requirement_id FROM documents WHERE id = ? AND deleted_at IS NULL').get(req.params.id); if (!doc) { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); return res.status(404).json({ message: 'Document not found' }); } const isTeacher = req.user?.role?.toLowerCase() === 'teacher'; if (isTeacher) { const hasAccess = db.prepare(` SELECT 1 FROM enrollments e JOIN teacher_sections ts ON e.section_id = ts.section_id WHERE e.student_id = ? AND ts.teacher_id = ? `).get(doc.student_id, req.user.id); if (!hasAccess) { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); return res.status(403).json({ message: 'Access denied.' }); } } const lastVersion = db.prepare( 'SELECT MAX(version_number) AS max_v FROM document_versions WHERE document_id = ?' ).get(req.params.id); const nextVersion = (lastVersion?.max_v ?? 0) + 1; const fileSize = file.size || (fs.existsSync(file.path) ? fs.statSync(file.path).size : null); const notes = req.body.notes ?? null; db.prepare(` INSERT INTO document_versions (document_id, version_number, file_name, file_path, file_size, uploaded_by, notes, template_id, template_version_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( req.params.id, nextVersion, file.originalname, file.path, fileSize, req.user.id, notes, req.body.templateId ?? null, req.body.templateVersionId ?? null ); // Update document header: file_name + file_path always reflect latest version db.prepare(` UPDATE documents SET file_name = ?, file_path = ?, file_size = ?, template_id = COALESCE(?, template_id), template_version_id = COALESCE(?, template_version_id) WHERE id = ? `).run( file.originalname, file.path, fileSize, req.body.templateId ?? null, req.body.templateVersionId ?? null, req.params.id ); logActivity(req.user.id, 'UPDATE', 'document', req.params.id, `Uploaded version ${nextVersion} for document id ${req.params.id}`); res.status(201).json({ message: `Version ${nextVersion} uploaded successfully`, version_number: nextVersion }); } catch (error) { console.error('uploadDocumentVersion error:', error); if (file && fs.existsSync(file.path)) fs.unlinkSync(file.path); res.status(500).json({ message: 'Failed to upload document version', error: error.message }); } }; // ============================================================ // GET /api/documents/:id/download — download latest version file // ============================================================ exports.downloadDocumentVersion = (req, res) => { try { const { versionId } = req.query; let filePath, fileName; if (versionId) { const ver = db.prepare(` SELECT dv.file_path, dv.file_name, d.student_id FROM document_versions dv JOIN documents d ON dv.document_id = d.id WHERE dv.id = ? AND dv.document_id = ? `).get(versionId, req.params.id); if (!ver) return res.status(404).json({ message: 'Version not found' }); filePath = ver.file_path; fileName = ver.file_name; } else { const doc = db.prepare('SELECT file_path, file_name FROM documents WHERE id = ? AND deleted_at IS NULL').get(req.params.id); if (!doc) return res.status(404).json({ message: 'Document not found' }); filePath = doc.file_path; fileName = doc.file_name; } if (!fs.existsSync(filePath)) return res.status(404).json({ message: 'File not found on disk' }); res.download(path.resolve(filePath), fileName); } catch (error) { console.error('downloadDocumentVersion error:', error); res.status(500).json({ message: 'Failed to download document version', error: error.message }); } };