diff --git a/backend/src/controllers/assignmentController.js b/backend/src/controllers/assignmentController.js index 7695e70..3920250 100644 --- a/backend/src/controllers/assignmentController.js +++ b/backend/src/controllers/assignmentController.js @@ -29,53 +29,121 @@ const stmts = { 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' }); +/* ════════════════════════════════════════════════════════════════════ + SHARED HELPERS for assignment creation + Used by all 3 endpoints: createAssignment, bulkCreateAssignment, + createDirectAssignment. Centralizes validation, resolution and INSERT. + ════════════════════════════════════════════════════════════════════ */ +/** + * Validate fields and resolve foreign keys (test_id, file_id, textbook_slug). + * Returns { ok: { ...fields }, error: null } or { ok: null, error: 'message' }. + * + * Doesn't touch the target (class/user) — that's the caller's responsibility. + */ +function _resolveAssignment(body) { + const { title, topic_id, deadline, test_id, file_id, is_homework = 0, + textbook_slug, textbook_paragraphs } = body || {}; + const mode = body?.mode || 'exam'; + const count = Number(body?.count) || 25; + const max_attempts = Math.max(0, Math.min(10, Number(body?.max_attempts) || 0)); + let subject_slug = body?.subject_slug; + + if (!title?.trim()) return { error: 'title required' }; + if (!VALID_ASSIGN_MODES.has(mode)) return { error: 'mode must be exam, practice, repeat or ct' }; + if (!Number.isInteger(count) || count < 1 || count > 200) + return { error: 'count must be an integer between 1 and 200' }; + if (deadline && isNaN(Date.parse(deadline))) + return { error: 'deadline must be a valid date' }; + + // Resolve test → subject if (test_id) { - const t = stmts.getTestSubject.get(test_id); - if (!t) return res.status(400).json({ error: 'Test not found' }); + const t = stmts.getTestSubject.get(Number(test_id)); + if (!t) return { error: 'Test not found' }; subject_slug = t.subject_slug; } + + // Resolve textbook → id + 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 { error: 'Учебник не найден' }; + textbook_id = tb.id; + if (!subject_slug) subject_slug = tb.subject; + } + + // Resolve file → subject (fallback) if (file_id && !subject_slug) { - const f = stmts.getFileSubject.get(file_id); + const f = stmts.getFileSubject.get(Number(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 && !is_homework) return { error: 'subject_slug required' }; if (!subject_slug) subject_slug = 'other'; + return { + ok: { + title: stripTags(title.trim()), + subject_slug, + mode, + count, + topic_id: topic_id || null, + deadline: deadline || null, + test_id: test_id ? Number(test_id) : null, + file_id: file_id ? Number(file_id) : null, + is_homework: is_homework ? 1 : 0, + max_attempts, + textbook_id, + textbook_paragraphs: textbook_paragraphs || null, + }, + }; +} + +const _insertAssignmentStmt = db.prepare(` + INSERT INTO assignments ( + class_id, user_id, title, subject_slug, mode, count, topic_id, + deadline, created_by, test_id, file_id, is_homework, max_attempts, + textbook_id, textbook_paragraphs + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +`); + +/** Insert one assignments row. target is { class_id } or { user_id }. */ +function _insertAssignment(target, fields, createdBy) { + const r = _insertAssignmentStmt.run( + target.class_id ?? null, + target.user_id ?? null, + fields.title, fields.subject_slug, fields.mode, fields.count, fields.topic_id, + fields.deadline, createdBy, fields.test_id, fields.file_id, fields.is_homework, + fields.max_attempts, fields.textbook_id, fields.textbook_paragraphs, + ); + return Number(r.lastInsertRowid); +} + +/** Send "assignment created" notification to all recipients. */ +function _notifyAssignment(target, title) { + const msg = `Новое задание: «${title}»`; + if (target.user_id) { + pushNotif(target.user_id, 'assignment', `Для вас задание: «${title}»`, '/dashboard'); + } else if (target.class_id) { + const members = stmts.getClassMembers.all(target.class_id); + members.forEach(m => pushNotif(m.user_id, 'assignment', msg, '/dashboard')); + } +} + +/* ── POST /api/classes/:id/assignments ── create assignment for one class ── */ +function createAssignment(req, res) { 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); + const { ok, error } = _resolveAssignment(req.body); + if (error) return res.status(400).json({ error }); - // Уведомления всем членам класса (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 }); + const id = _insertAssignment({ class_id: cls.id }, ok, req.user.id); + _notifyAssignment({ class_id: cls.id }, ok.title); + res.status(201).json({ id }); } /* ── PUT /api/assignments/:id ── update ───────────────────────────────── */ @@ -506,18 +574,9 @@ function assignmentQuestionStats(req, res) { /* ── 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' }); + const { student_email, student_id, is_homework = 1 } = req.body; + // Resolve student by id or email 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)); @@ -529,7 +588,7 @@ function createDirectAssignment(req, res) { if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' }); } - // Учитель может выдать личное задание ученику из своего класса ИЛИ из «Мои ученики» + // Authorization: teacher can assign only to students in their classes OR in their «Мои ученики» if (req.user.role === 'teacher') { const inClass = db.prepare(` SELECT 1 FROM class_members cm @@ -546,37 +605,13 @@ function createDirectAssignment(req, res) { } } - 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; - } + // is_homework defaults to 1 for direct (vs 0 for class/bulk) + const { ok, error } = _resolveAssignment({ is_homework, ...req.body }); + if (error) return res.status(400).json({ error }); - 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 }); + const id = _insertAssignment({ user_id: student.id }, ok, req.user.id); + _notifyAssignment({ user_id: student.id }, ok.title); + res.status(201).json({ id }); } /* ── GET /api/assignments/:id/sessions/:session_id/review ── teacher view ── */ @@ -666,48 +701,23 @@ function deleteTemplate(req, res) { /* ── 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; - + const { class_ids } = 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 { ok, error } = _resolveAssignment(req.body); + if (error) return res.status(400).json({ error }); + // Authorize each class once, then INSERT + notify in a single transaction 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); + const cls = stmts.getClass.get(Number(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')); + const id = _insertAssignment({ class_id: cls.id }, ok, req.user.id); + ids.push(id); + _notifyAssignment({ class_id: cls.id }, ok.title); } return ids; })();