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:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
@@ -0,0 +1,652 @@
const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize');
const { SESSION_MODES } = require('../constants');
const VALID_ASSIGN_MODES = SESSION_MODES;
/* ── Prepared statements (module-level to avoid re-parsing per request) ── */
const stmts = {
getTestSubject: db.prepare('SELECT subject_slug FROM tests WHERE id = ?'),
getFileSubject: db.prepare('SELECT subject_slug FROM files WHERE id = ?'),
getClass: db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?'),
getClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ?'),
getSubjectBySlug: db.prepare('SELECT id FROM subjects WHERE slug = ?'),
countCompletedSess: db.prepare(`
SELECT COUNT(*) AS n FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = ? AND ax.user_id = ?
`),
getInProgressSess: db.prepare(`
SELECT ax.session_id FROM assignment_sessions ax
JOIN test_sessions ts ON ts.id = ax.session_id AND ts.status = 'in_progress'
WHERE ax.assignment_id = ? AND ax.user_id = ?
ORDER BY ax.id DESC LIMIT 1
`),
insertSession: db.prepare('INSERT INTO test_sessions (user_id, subject_id, mode, total) VALUES (?, ?, ?, ?)'),
insertSessionQ: db.prepare('INSERT INTO session_questions (session_id, question_id, order_index) VALUES (?, ?, ?)'),
insertAssignSess: db.prepare('INSERT INTO assignment_sessions (assignment_id, user_id, session_id, attempt_num) VALUES (?, ?, ?, ?)'),
notifyClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ? AND user_id != ?'),
};
/* ── POST /api/classes/:id/assignments ── create assignment ─────────────── */
function createAssignment(req, res) {
const { title, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
const max_attempts = Math.max(0, Math.min(10, Number(req.body.max_attempts) || 0));
let { subject_slug } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
const cleanTitle = stripTags(title.trim());
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam, practice, repeat or ct' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
if (deadline && isNaN(Date.parse(deadline)))
return res.status(400).json({ error: 'deadline must be a valid date' });
if (test_id) {
const t = stmts.getTestSubject.get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (file_id && !subject_slug) {
const f = stmts.getFileSubject.get(file_id);
if (f?.subject_slug) subject_slug = f.subject_slug;
}
// Upload-only homework doesn't require subject
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const cls = stmts.getClass.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const r = db.prepare(`
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework, max_attempts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(cls.id, cleanTitle, subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0, max_attempts);
// Уведомления всем членам класса (batch via transaction)
const members = stmts.getClassMembers.all(cls.id);
const notifMsg = `Новое задание: «${cleanTitle}»`;
const insertNotif = db.transaction(() => {
members.forEach(m => pushNotif(m.user_id, 'assignment', notifMsg, '/dashboard'));
});
insertNotif();
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── PUT /api/assignments/:id ── update ───────────────────────────────── */
function updateAssignment(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { title, mode, count, deadline } = req.body;
let { subject_slug, test_id } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
test_id = test_id ? Number(test_id) : null;
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
db.prepare(`
UPDATE assignments SET title = ?, subject_slug = ?, mode = ?, count = ?, deadline = ?, test_id = ? WHERE id = ?
`).run(stripTags(title.trim()), subject_slug, mode || 'exam', Number(count) || 25, deadline || null, test_id, req.params.id);
res.json({ ok: true });
}
/* ── DELETE /api/assignments/:id ──────────────────────────────────────── */
function deleteAssignment(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id);
res.json({ ok: true });
}
/* ── GET /api/assignments/teacher ── teacher: own created assignments ──── */
function teacherAssignments(req, res) {
const isAdmin = req.user.role === 'admin';
// Админ видит все задания без ограничений
if (isAdmin) {
const rows = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.test_id, t.title AS test_title,
a.file_id, f.title AS file_title,
a.user_id AS target_user_id, tu.name AS target_user_name,
COALESCE(c.name, 'Личное задание') AS class_name,
COALESCE(c.id, 0) AS class_id,
CASE WHEN a.user_id IS NOT NULL THEN 1 ELSE COUNT(DISTINCT cm.user_id) END AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
FROM assignments a
LEFT JOIN classes c ON c.id = a.class_id AND a.class_id IS NOT NULL
LEFT JOIN users tu ON tu.id = a.user_id
LEFT JOIN tests t ON t.id = a.test_id
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
GROUP BY a.id
ORDER BY a.created_at DESC
`).all();
return res.json(rows);
}
// Учитель видит только свои задания:
// - классовые (любые свои)
// - личные — только для учеников из своих классов
const rows = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.test_id, t.title AS test_title,
a.file_id, f.title AS file_title,
a.user_id AS target_user_id, tu.name AS target_user_name,
COALESCE(c.name, 'Личное задание') AS class_name,
COALESCE(c.id, 0) AS class_id,
CASE WHEN a.user_id IS NOT NULL THEN 1 ELSE COUNT(DISTINCT cm.user_id) END AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
FROM assignments a
LEFT JOIN classes c ON c.id = a.class_id AND a.class_id IS NOT NULL
LEFT JOIN users tu ON tu.id = a.user_id
LEFT JOIN tests t ON t.id = a.test_id
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
WHERE a.created_by = ?
AND (
a.class_id IS NOT NULL
OR (
a.user_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM class_members cm2
JOIN classes c2 ON c2.id = cm2.class_id
WHERE cm2.user_id = a.user_id AND c2.teacher_id = ?
)
)
)
GROUP BY a.id
ORDER BY a.created_at DESC
`).all(req.user.id, req.user.id);
res.json(rows);
}
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done,
a.is_homework, a.max_attempts,
(SELECT COUNT(*) FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = a.id AND ax.user_id = cm.user_id) AS attempts_used
FROM class_members cm
JOIN classes c ON c.id = cm.class_id
JOIN users u ON u.id = c.teacher_id
JOIN assignments a ON a.class_id = c.id AND a.user_id IS NULL
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = cm.user_id
AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = cm.user_id)
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
WHERE cm.user_id = ?
UNION ALL
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done,
a.is_homework, a.max_attempts,
(SELECT COUNT(*) FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = a.id AND ax.user_id = ?) AS attempts_used
FROM assignments a
JOIN users u ON u.id = a.created_by
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = ?
AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = ?)
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
WHERE a.user_id = ?
) ORDER BY done ASC, deadline ASC, created_at DESC
`).all(uid, uid, uid, uid, uid);
res.json(rows);
}
/* ── POST /api/assignments/:id/start ── student starts session ─────────── */
function startAssignment(req, res) {
const uid = req.user.id;
const assignment = db.prepare(`
SELECT a.* FROM assignments a
WHERE a.id = ?
AND (
(a.class_id IS NOT NULL AND EXISTS (
SELECT 1 FROM class_members WHERE class_id = a.class_id AND user_id = ?
))
OR a.user_id = ?
)
`).get(req.params.id, uid, uid);
if (!assignment) return res.status(404).json({ error: 'Assignment not found or not your class' });
// Deadline check: reject if deadline has already passed
if (assignment.deadline) {
const dl = new Date(assignment.deadline.includes('T') ? assignment.deadline : assignment.deadline.replace(' ', 'T') + 'Z');
if (dl < new Date()) return res.status(403).json({ error: 'Срок выполнения задания истёк' });
}
// File-only assignment: just return the download URL, no session needed
if (assignment.file_id && !assignment.test_id) {
return res.json({ is_file: true, file_id: assignment.file_id });
}
// assignment mode → session mode mapping
const SESSION_MODE = { exam: 'exam', practice: 'practice', repeat: 'practice', ct: 'exam' };
const sessionMode = SESSION_MODE[assignment.mode] || 'exam';
// Count completed attempts for this student
const completedCount = stmts.countCompletedSess.get(req.params.id, uid).n;
// Check attempt limit
const maxAttempts = assignment.max_attempts || 0;
if (maxAttempts > 0 && completedCount >= maxAttempts) {
return res.status(403).json({
error: 'Исчерпан лимит попыток',
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
// Check for an existing in-progress session
const inProgress = stmts.getInProgressSess.get(req.params.id, uid);
if (inProgress?.session_id) {
return res.json({
session_id: inProgress.session_id,
already_started: true,
status: 'in_progress',
assignment_mode: assignment.mode,
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
const subject = stmts.getSubjectBySlug.get(assignment.subject_slug);
if (!subject) return res.status(400).json({ error: 'Invalid subject' });
let questionIds;
if (assignment.test_id) {
// Use exact questions from the pre-made test (in defined order)
questionIds = db.prepare(
'SELECT question_id FROM test_questions WHERE test_id = ? ORDER BY order_index'
).all(assignment.test_id).map(r => r.question_id);
} else if (assignment.mode === 'ct') {
// CT mode: Part A (single/true_false) first, then Part B (multi/short_answer)
const baseWhere = `subject_id = ?${assignment.topic_id ? ' AND topic_id = ?' : ''}`;
const baseArgs = assignment.topic_id ? [subject.id, assignment.topic_id] : [subject.id];
const half = Math.ceil(assignment.count / 2);
const partA = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND type IN ('single','true_false') ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, half).map(q => q.id);
const partB = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND type IN ('multi','short_answer') ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, assignment.count - partA.length).map(q => q.id);
const got = partA.length + partB.length;
const usedIds = [...partA, ...partB];
const extra = (got < assignment.count && usedIds.length > 0)
? db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND id NOT IN (${usedIds.map(() => '?').join(',')}) ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, ...usedIds, assignment.count - got).map(q => q.id)
: [];
questionIds = [...partA, ...partB, ...extra];
} else {
const baseWhere = `subject_id = ?${assignment.topic_id ? ' AND topic_id = ?' : ''}`;
const baseArgs = assignment.topic_id ? [subject.id, assignment.topic_id] : [subject.id];
questionIds = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, assignment.count).map(q => q.id);
}
if (!questionIds.length) return res.status(400).json({ error: 'No questions available' });
const session_id = db.transaction(() => {
const { lastInsertRowid: sid } = stmts.insertSession.run(uid, subject.id, sessionMode, questionIds.length);
questionIds.forEach((qid, i) => stmts.insertSessionQ.run(sid, qid, i));
stmts.insertAssignSess.run(req.params.id, uid, sid, completedCount + 1);
return sid;
})();
res.json({
session_id,
assignment_mode: assignment.mode,
attempt_num: completedCount + 1,
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
/* ── GET /api/assignments/:id/results ── teacher view ──────────────────── */
function assignmentResults(req, res) {
const a = db.prepare(`
SELECT a.*, COALESCE(c.teacher_id, a.created_by) AS teacher_id, c.name AS class_name
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
let results;
if (a.user_id) {
// Direct assignment: single student — pick best attempt
results = db.prepare(`
SELECT u.id, u.name, u.email,
best.session_id,
best.score, best.total, best.session_status, best.finished_at,
best.percent,
best.attempts_used
FROM users u
LEFT JOIN (
SELECT ases.user_id,
ases.session_id,
ts.score, ts.total, ts.status AS session_status, ts.finished_at,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
COUNT(*) OVER (PARTITION BY ases.assignment_id, ases.user_id) AS attempts_used,
ROW_NUMBER() OVER (PARTITION BY ases.user_id ORDER BY ts.score DESC, ts.finished_at DESC) AS rn
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ?
) best ON best.user_id = u.id AND best.rn = 1
WHERE u.id = ?
`).all(req.params.id, a.user_id);
} else {
results = db.prepare(`
SELECT u.id, u.name, u.email,
best.session_id,
best.score, best.total, best.session_status, best.finished_at,
best.percent,
best.attempts_used
FROM class_members cm
JOIN users u ON u.id = cm.user_id
LEFT JOIN (
SELECT ases.user_id,
ases.session_id,
ts.score, ts.total, ts.status AS session_status, ts.finished_at,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
COUNT(*) OVER (PARTITION BY ases.assignment_id, ases.user_id) AS attempts_used,
ROW_NUMBER() OVER (PARTITION BY ases.user_id ORDER BY ts.score DESC, ts.finished_at DESC) AS rn
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ?
) best ON best.user_id = cm.user_id AND best.rn = 1
WHERE cm.class_id = ?
ORDER BY
CASE WHEN best.percent IS NULL THEN 1 ELSE 0 END,
best.percent DESC, u.name
`).all(req.params.id, a.class_id);
}
res.json({ assignment: a, results });
}
/* ── GET /api/assignments/:id/question-stats ── per-question error rates ── */
function assignmentQuestionStats(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id, a.class_id, a.user_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
// All completed sessions for this assignment
const sessions = db.prepare(`
SELECT ases.session_id FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ? AND ts.status = 'completed'
`).all(req.params.id);
if (!sessions.length) return res.json({ stats: [] });
const sessionIds = sessions.map(s => s.session_id);
const placeholders = sessionIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT q.id AS question_id,
q.text AS question_text,
q.type,
COUNT(ua.id) AS total,
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
ROUND(
CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
/ COUNT(ua.id) * 100
, 0) AS error_pct
FROM user_answers ua
JOIN questions q ON q.id = ua.question_id
WHERE ua.session_id IN (${placeholders})
GROUP BY ua.question_id
ORDER BY error_pct DESC, wrong DESC
`).all(...sessionIds);
res.json({ stats: rows, session_count: sessionIds.length });
}
/* ── POST /api/assignments ── direct assignment to a single student ──────── */
function createDirectAssignment(req, res) {
const { deadline, student_email, student_id, file_id, is_homework = 1 } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
let { title, subject_slug, test_id } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam, practice, repeat or ct' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
if (deadline && isNaN(Date.parse(deadline)))
return res.status(400).json({ error: 'deadline must be a valid date' });
let student;
if (student_id) {
student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'").get(Number(student_id));
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
} else {
if (!student_email?.trim()) return res.status(400).json({ error: 'student_email required' });
student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'")
.get(student_email.trim().toLowerCase());
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
}
// Учитель может выдать личное задание только ученику из своего класса
if (req.user.role === 'teacher') {
const inClass = db.prepare(`
SELECT 1 FROM class_members cm
JOIN classes c ON c.id = cm.class_id
WHERE cm.user_id = ? AND c.teacher_id = ?
`).get(student.id, req.user.id);
if (!inClass) return res.status(403).json({ error: 'Ученик не входит ни в один из ваших классов' });
}
test_id = test_id ? Number(test_id) : null;
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (file_id && !subject_slug) {
const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id);
if (f?.subject_slug) subject_slug = f.subject_slug;
}
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const r = db.prepare(`
INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0);
// Уведомление ученику
pushNotif(student.id, 'assignment', `Для вас задание: «${title.trim()}»`, '/dashboard');
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/assignments/:id/sessions/:session_id/review ── teacher view ── */
function assignmentSessionReview(req, res) {
const assignmentId = Number(req.params.id);
const sessionId = Number(req.params.session_id);
// Verify assignment ownership
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(assignmentId);
if (!a) return res.status(404).json({ error: 'Assignment not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
// Verify session is linked to this assignment
const link = db.prepare(
'SELECT 1 FROM assignment_sessions WHERE assignment_id = ? AND session_id = ?'
).get(assignmentId, sessionId);
if (!link) return res.status(404).json({ error: 'Session not linked to this assignment' });
const session = db.prepare('SELECT score, total, status FROM test_sessions WHERE id = ?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Session not found' });
// Build per-question review — batched
const questionIds = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(sessionId).map(r => r.question_id);
if (!questionIds.length) return res.json({ session_id: sessionId, score: session.score, total: session.total, review: [] });
const qPh = questionIds.map(() => '?').join(',');
const questions = db.prepare(`SELECT id, text, type, explanation, correct_text FROM questions WHERE id IN (${qPh})`).all(...questionIds);
const qMap = {};
for (const q of questions) qMap[q.id] = q;
const allOptions = db.prepare(`SELECT question_id, id, text, is_correct, match_pair FROM options WHERE question_id IN (${qPh}) ORDER BY order_index`).all(...questionIds);
const optMap = {};
for (const o of allOptions) { if (!optMap[o.question_id]) optMap[o.question_id] = []; optMap[o.question_id].push(o); }
const allAnswers = db.prepare(`SELECT question_id, chosen_option_id, answer_text, is_correct FROM user_answers WHERE session_id = ? AND question_id IN (${qPh})`).all(sessionId, ...questionIds);
const ansMap = {};
for (const a of allAnswers) ansMap[a.question_id] = a;
const review = questionIds.map(qid => {
const q = qMap[qid];
if (!q) return null;
q.options = optMap[qid] || [];
const ua = ansMap[qid];
q.chosen_option_id = ua?.chosen_option_id ?? null;
q.answer_text = ua?.answer_text ?? null;
q.is_correct = ua ? ua.is_correct === 1 : null;
return q;
}).filter(Boolean);
res.json({ session_id: sessionId, score: session.score, total: session.total, review });
}
/* ── GET /api/assignments/templates ── list my templates ───────────────── */
function listTemplates(req, res) {
const rows = db.prepare(`
SELECT id, label, subject_slug, mode, count, topic_id, test_id, file_id, is_homework, created_at
FROM assignment_templates WHERE created_by = ? ORDER BY created_at DESC
`).all(req.user.id);
res.json(rows);
}
/* ── POST /api/assignments/templates ── save template ──────────────────── */
function saveTemplate(req, res) {
const { label, subject_slug, mode = 'exam', count = 25, topic_id, test_id, file_id, is_homework = 0 } = req.body;
if (!label?.trim()) return res.status(400).json({ error: 'label required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const r = db.prepare(`
INSERT INTO assignment_templates (created_by, label, subject_slug, mode, count, topic_id, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(req.user.id, label.trim(), subject_slug, mode, Number(count), topic_id || null, test_id || null, file_id || null, is_homework ? 1 : 0);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── DELETE /api/assignments/templates/:id ─────────────────────────────── */
function deleteTemplate(req, res) {
db.prepare('DELETE FROM assignment_templates WHERE id = ? AND created_by = ?')
.run(req.params.id, req.user.id);
res.json({ ok: true });
}
/* ── POST /api/assignments/bulk ── assign to multiple classes at once ───── */
function bulkCreateAssignment(req, res) {
const { class_ids, title, mode = 'exam', count = 25, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body;
let { subject_slug } = req.body;
if (!Array.isArray(class_ids) || !class_ids.length)
return res.status(400).json({ error: 'class_ids[] required' });
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'invalid mode' });
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const created = db.transaction(() => {
const ids = [];
for (const class_id of class_ids) {
const cls = db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?').get(class_id);
if (!cls) continue;
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) continue;
const r = db.prepare(`
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(cls.id, stripTags(title.trim()), subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0);
ids.push(r.lastInsertRowid);
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(cls.id);
members.forEach(m => pushNotif(m.user_id, 'assignment', `Новое задание: «${title.trim()}»`, '/dashboard'));
}
return ids;
})();
res.status(201).json({ created, count: created.length });
}
module.exports = {
VALID_ASSIGN_MODES,
createAssignment,
updateAssignment,
deleteAssignment,
teacherAssignments,
myAssignments,
startAssignment,
assignmentResults,
assignmentQuestionStats,
createDirectAssignment,
assignmentSessionReview,
listTemplates,
saveTemplate,
deleteTemplate,
bulkCreateAssignment,
};