Files
Learn_System/backend/src/controllers/submissionsController.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

319 lines
14 KiB
JavaScript

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 };