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>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user