const db = require('../db/db'); const path = require('path'); const fs = require('fs'); const { pushNotif, pushParentNotif } = require('../utils/notifications'); const { UPLOADS_DIR } = require('../config'); const { checkMagicBytes } = require('../utils/magic'); /* ── POST /api/submissions (student) ─────────────────────────────────── */ function submit(req, res) { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const { assignment_id, class_id, message } = req.body; const student_id = req.user.id; if (!class_id) return res.status(400).json({ error: 'class_id required' }); const member = db.prepare( 'SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?' ).get(Number(class_id), student_id); if (!member) return res.status(403).json({ error: 'Not a member of this class' }); if (assignment_id) { const assign = db.prepare( 'SELECT id FROM assignments WHERE id = ? AND class_id = ?' ).get(Number(assignment_id), Number(class_id)); if (!assign) return res.status(400).json({ error: 'Assignment not found in class' }); } // Magic bytes verification — reject spoofed MIME types const uploadedPath = path.resolve(UPLOADS_DIR, req.file.filename); if (!checkMagicBytes(uploadedPath, req.file.mimetype)) { try { fs.unlinkSync(uploadedPath); } catch {} return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' }); } let r; try { r = db.prepare(` INSERT INTO submissions (class_id, assignment_id, student_id, original_name, stored_name, mimetype, size, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `).run( Number(class_id), assignment_id ? Number(assignment_id) : null, student_id, req.file.originalname, req.file.filename, req.file.mimetype, req.file.size, message?.trim() || null ); } catch (err) { const fp = path.resolve(UPLOADS_DIR, req.file.filename); if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} } throw err; } // Notify teacher that a student submitted work try { const cls = db.prepare('SELECT teacher_id, name FROM classes WHERE id = ?').get(Number(class_id)); if (cls?.teacher_id) { const assignTitle = assignment_id ? db.prepare('SELECT title FROM assignments WHERE id = ?').get(Number(assignment_id))?.title : null; const studentName = req.user.name || req.user.email; const msg = assignTitle ? `«${studentName}» сдал работу по заданию «${assignTitle}»` : `«${studentName}» прикрепил работу в классе «${cls.name}»`; pushNotif(cls.teacher_id, 'submission', msg, '/classes'); } } catch (e) { console.error('[submissions] notify teacher:', e.message); } res.status(201).json({ id: r.lastInsertRowid }); } /* ── GET /api/submissions/my (student) ───────────────────────────────── */ function getMySubmissions(req, res) { const rows = db.prepare(` SELECT s.id, s.class_id, s.assignment_id, s.original_name, s.size, s.mimetype, s.message, s.status, s.teacher_note, s.grade, s.submitted_at, a.title AS assignment_title FROM submissions s LEFT JOIN assignments a ON a.id = s.assignment_id WHERE s.student_id = ? ORDER BY s.submitted_at DESC `).all(req.user.id); res.json(rows); } /* ── GET /api/submissions?class_id=X (teacher/admin) ─────────────────── */ function getClassSubmissions(req, res) { const { class_id } = req.query; if (!class_id) return res.status(400).json({ error: 'class_id required' }); if (req.user.role === 'teacher') { const cls = db.prepare('SELECT teacher_id FROM classes WHERE id = ?').get(Number(class_id)); if (!cls) return res.status(404).json({ error: 'Class not found' }); if (cls.teacher_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); } const rows = db.prepare(` SELECT s.id, s.class_id, s.assignment_id, s.original_name, s.size, s.mimetype, s.message, s.status, s.teacher_note, s.grade, s.submitted_at, u.name AS student_name, u.email AS student_email, a.title AS assignment_title FROM submissions s JOIN users u ON u.id = s.student_id LEFT JOIN assignments a ON a.id = s.assignment_id WHERE s.class_id = ? ORDER BY s.submitted_at DESC `).all(Number(class_id)); res.json(rows); } /* ── PATCH /api/submissions/:id (teacher/admin) ──────────────────────── */ function reviewSubmission(req, res) { const sub = db.prepare(` SELECT s.*, c.teacher_id FROM submissions s JOIN classes c ON c.id = s.class_id WHERE s.id = ? `).get(Number(req.params.id)); if (!sub) return res.status(404).json({ error: 'Submission not found' }); if (req.user.role === 'teacher' && sub.teacher_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); const VALID_STATUSES = ['new', 'reviewed', 'revision', 'resubmitted', 'accepted']; const { status, teacher_note, grade } = req.body; if (status && !VALID_STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' }); if (grade !== undefined && grade !== null) { const g = Number(grade); if (!Number.isInteger(g) || g < 0 || g > 100) return res.status(400).json({ error: 'Grade must be integer 0-100' }); } const gradeVal = grade === undefined ? sub.grade : (grade === null || grade === '') ? null : Number(grade); const noteVal = teacher_note !== undefined ? (teacher_note?.trim() || null) : sub.teacher_note; const statusVal = status || sub.status; const reviewedAt = (status === 'reviewed' || status === 'accepted') ? new Date().toISOString() : sub.reviewed_at; db.prepare(` UPDATE submissions SET status=?, teacher_note=?, grade=?, reviewed_at=? WHERE id=? `).run(statusVal, noteVal, gradeVal, reviewedAt, sub.id); // Notify student try { const assignTitle = sub.assignment_id ? db.prepare('SELECT title FROM assignments WHERE id = ?').get(sub.assignment_id)?.title : null; const isGraded = gradeVal !== undefined && gradeVal !== null; if (status === 'revision') { const msg = assignTitle ? `Работа «${assignTitle}» отправлена на доработку.${noteVal ? ' Комментарий: ' + noteVal : ''}` : `Ваша работа отправлена на доработку.`; pushNotif(sub.student_id, 'revision', msg, '/homework'); } else if (status === 'accepted' || (status === 'reviewed' && sub.status !== 'reviewed')) { const gradeText = isGraded ? ` Оценка: ${gradeVal}/100` : ''; const statusText = status === 'accepted' ? 'принята' : 'проверена'; const msg = assignTitle ? `Ваша работа «${assignTitle}» ${statusText}.${gradeText}` : `Ваша работа ${statusText}.${gradeText}`; pushNotif(sub.student_id, 'grade', msg, '/homework'); } else if (isGraded && !status) { const msg = assignTitle ? `Оценка за «${assignTitle}»: ${gradeVal}/100` : `Оценка за работу: ${gradeVal}/100`; pushNotif(sub.student_id, 'grade', msg, '/homework'); } // Notify parents if (isGraded || status === 'accepted' || status === 'reviewed') { const gradeText = isGraded ? ` Оценка: ${gradeVal}/100` : ''; const parentMsg = assignTitle ? `Работа «${assignTitle}» проверена.${gradeText}` : `Работа проверена.${gradeText}`; pushParentNotif(sub.student_id, 'grade', parentMsg); } } catch (e) { console.error('[submissions] notify student grade:', e.message); } res.json({ ok: true }); } /* ── GET /api/submissions/:id/download ────────────────────────────────── */ function downloadSubmission(req, res) { const sub = db.prepare(` SELECT s.*, c.teacher_id FROM submissions s JOIN classes c ON c.id = s.class_id WHERE s.id = ? `).get(Number(req.params.id)); if (!sub) return res.status(404).json({ error: 'Submission not found' }); const uid = req.user.id; const isTeacher = ['teacher', 'admin'].includes(req.user.role); if (!isTeacher && sub.student_id !== uid) return res.status(403).json({ error: 'Forbidden' }); if (req.user.role === 'teacher' && sub.teacher_id !== uid) return res.status(403).json({ error: 'Forbidden' }); const filePath = path.resolve(UPLOADS_DIR, sub.stored_name); if (!filePath.startsWith(UPLOADS_DIR + path.sep)) return res.status(400).json({ error: 'Invalid path' }); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing' }); const encoded = encodeURIComponent(sub.original_name); res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`); res.setHeader('Content-Type', sub.mimetype || 'application/octet-stream'); res.sendFile(filePath); } /* ── DELETE /api/submissions/:id ───────────────────────────────────────── */ function deleteSubmission(req, res) { const sub = db.prepare('SELECT s.*, c.teacher_id FROM submissions s JOIN classes c ON c.id = s.class_id WHERE s.id = ?') .get(Number(req.params.id)); if (!sub) return res.status(404).json({ error: 'Submission not found' }); const isOwner = sub.student_id === req.user.id; const isAdmin = req.user.role === 'admin'; const isTeacher = req.user.role === 'teacher' && sub.teacher_id === req.user.id; if (!isOwner && !isAdmin && !isTeacher) return res.status(403).json({ error: 'Forbidden' }); // Students can't delete reviewed/accepted submissions; teachers/admin can if (isOwner && !isAdmin && !isTeacher && ['reviewed', 'accepted'].includes(sub.status)) return res.status(400).json({ error: 'Cannot delete a reviewed submission' }); // Audit log — record who deleted what const studentName = db.prepare('SELECT name FROM users WHERE id = ?').get(sub.student_id)?.name || ''; db.prepare(` INSERT INTO submission_log (submission_id, class_id, assignment_id, student_id, student_name, original_name, status, grade, teacher_note, submitted_at, action, deleted_by, deleted_by_role) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'deleted', ?, ?) `).run(sub.id, sub.class_id, sub.assignment_id, sub.student_id, studentName, sub.original_name, sub.status, sub.grade, sub.teacher_note, sub.submitted_at, req.user.id, req.user.role); const filePath = path.resolve(UPLOADS_DIR, sub.stored_name); if (filePath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(filePath); } catch {} } db.prepare('DELETE FROM submissions WHERE id = ?').run(sub.id); res.json({ ok: true }); } /* ── POST /api/submissions/:id/resubmit (student — only if revision) ──── */ function resubmit(req, res) { if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); const sub = db.prepare('SELECT * FROM submissions WHERE id = ?').get(Number(req.params.id)); if (!sub) return res.status(404).json({ error: 'Submission not found' }); if (sub.student_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); if (sub.status !== 'revision') return res.status(400).json({ error: 'Resubmit only allowed for revision status' }); // Magic bytes verification on resubmit const resubPath = path.resolve(UPLOADS_DIR, req.file.filename); if (!checkMagicBytes(resubPath, req.file.mimetype)) { try { fs.unlinkSync(resubPath); } catch {} return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' }); } // Delete old file const oldPath = path.resolve(UPLOADS_DIR, sub.stored_name); if (oldPath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(oldPath); } catch {} } const message = req.body.message?.trim() || null; try { db.prepare(` UPDATE submissions SET original_name=?, stored_name=?, mimetype=?, size=?, message=?, status='resubmitted', submitted_at=datetime('now'), teacher_note=NULL WHERE id=? `).run(req.file.originalname, req.file.filename, req.file.mimetype, req.file.size, message, sub.id); } catch (err) { const fp = path.resolve(UPLOADS_DIR, req.file.filename); if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} } throw err; } // Notify teacher try { const cls = db.prepare('SELECT teacher_id, name FROM classes WHERE id = ?').get(sub.class_id); if (cls?.teacher_id) { const assignTitle = sub.assignment_id ? db.prepare('SELECT title FROM assignments WHERE id = ?').get(sub.assignment_id)?.title : null; const studentName = req.user.name || req.user.email; const msg = assignTitle ? `«${studentName}» повторно сдал работу «${assignTitle}»` : `«${studentName}» повторно сдал работу`; pushNotif(cls.teacher_id, 'submission', msg, '/homework'); } } catch (e) { console.error('[submissions] notify teacher resubmit:', e.message); } res.json({ ok: true }); } /* ── GET /api/submissions/log?class_id=X (admin only) ─────────────────── */ function getSubmissionLog(req, res) { const { class_id } = req.query; let sql = ` SELECT sl.*, u.name AS deleted_by_name, c.name AS class_name, a.title AS assignment_title FROM submission_log sl LEFT JOIN users u ON u.id = sl.deleted_by LEFT JOIN classes c ON c.id = sl.class_id LEFT JOIN assignments a ON a.id = sl.assignment_id `; const args = []; if (class_id) { sql += ' WHERE sl.class_id = ?'; args.push(Number(class_id)); } sql += ' ORDER BY sl.deleted_at DESC LIMIT 200'; res.json(db.prepare(sql).all(...args)); } /* ── DELETE /api/submissions/log (admin only) ─────────────────────────── */ function clearSubmissionLog(req, res) { db.prepare('DELETE FROM submission_log').run(); res.json({ ok: true }); } module.exports = { submit, getMySubmissions, getClassSubmissions, reviewSubmission, downloadSubmission, deleteSubmission, resubmit, getSubmissionLog, clearSubmissionLog };