be4d43105e
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>
319 lines
14 KiB
JavaScript
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 };
|