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,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,
|
||||
};
|
||||
Reference in New Issue
Block a user