1661 lines
70 KiB
JavaScript
1661 lines
70 KiB
JavaScript
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 });
|
|
}
|
|
};
|