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, a.textbook_id, a.textbook_paragraphs, tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count, tp.paragraphs_read AS textbook_read, 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, ac.completed_at AS completed_at, 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 textbooks tb ON tb.id = a.textbook_id LEFT JOIN textbook_progress tp ON tp.user_id = cm.user_id AND tp.textbook_id = a.textbook_id LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = cm.user_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, a.textbook_id, a.textbook_paragraphs, tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count, tp.paragraphs_read AS textbook_read, 'Личное задание' 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, ac.completed_at AS completed_at, 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 textbooks tb ON tb.id = a.textbook_id LEFT JOIN textbook_progress tp ON tp.user_id = ? AND tp.textbook_id = a.textbook_id LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_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, uid, uid); // Post-process: compute textbook reading completion from required vs read paragraphs for (const r of rows) { if (r.textbook_id) { const required = parseTextbookParas(r.textbook_paragraphs, r.textbook_para_count); let read = []; try { read = JSON.parse(r.textbook_read || '[]'); } catch {} const readKeys = new Set(read); const requiredKeys = required.map(n => 'p' + n); const readCount = requiredKeys.filter(k => readKeys.has(k)).length; r.textbook_required_count = requiredKeys.length; r.textbook_read_count = readCount; r.textbook_all_read = requiredKeys.length > 0 && readCount === requiredKeys.length; if (r.textbook_all_read || r.completed_at) r.done = 1; } // Strip raw paragraphs_read JSON from response (not needed by client) delete r.textbook_read; } res.json(rows); } /* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc. If empty/null, returns [1..fallback] (the whole book). */ function parseTextbookParas(spec, fallback) { if (!spec || !spec.trim()) { return Array.from({ length: fallback || 0 }, (_, i) => i + 1); } const out = new Set(); for (const chunk of spec.split(',')) { const part = chunk.trim(); if (!part) continue; const dash = part.match(/^(\d+)\s*[-–]\s*(\d+)$/); if (dash) { const a = Number(dash[1]), b = Number(dash[2]); for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i); } else if (/^\d+$/.test(part)) { out.add(Number(part)); } } return [...out].sort((a, b) => a - b); } /* ── 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, textbook_slug, textbook_paragraphs } = 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 IN ('student','free_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 IN ('student','free_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; } // Textbook: resolve slug → id, derive subject let textbook_id = null; if (textbook_slug) { const tb = db.prepare('SELECT id, subject FROM textbooks WHERE slug=? AND is_active=1').get(textbook_slug); if (!tb) return res.status(400).json({ error: 'Учебник не найден' }); textbook_id = tb.id; if (!subject_slug) subject_slug = tb.subject; } 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, textbook_id, textbook_paragraphs) 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, textbook_id, textbook_paragraphs || null); // Уведомление ученику 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, textbook_slug, textbook_paragraphs } = 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; } // Textbook: resolve slug → id, derive subject from textbook let textbook_id = null; if (textbook_slug) { const tb = db.prepare('SELECT id, subject FROM textbooks WHERE slug=? AND is_active=1').get(textbook_slug); if (!tb) return res.status(400).json({ error: 'Учебник не найден' }); textbook_id = tb.id; if (!subject_slug) subject_slug = tb.subject; } 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, textbook_id, textbook_paragraphs) 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, textbook_id, textbook_paragraphs || null); 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, };