refactor: unify assignment creation — 3 endpoints через общие helpers

Все три endpoint'а POST для создания assignment теперь используют
общую логику валидации, резолва FK и INSERT'а:

  POST /api/classes/:id/assignments  → createAssignment
  POST /api/assignments              → createDirectAssignment
  POST /api/assignments/bulk         → bulkCreateAssignment

Три новых private helper'а:
  _resolveAssignment(body) — валидирует и резолвит test_id/textbook_slug/
    file_id → возвращает {ok: {...resolved}, error: '...'}
  _insertAssignmentStmt — единственный prepared INSERT с полным
    набором колонок включая textbook_id, textbook_paragraphs
  _insertAssignment(target, fields, creatorId) — обёртка над INSERT,
    target = {class_id} или {user_id}
  _notifyAssignment(target, title) — pushNotif для class members или
    одного user в зависимости от target

Каждая из трёх public-функций теперь:
  - 25-40 строк (было 50-80)
  - Уникальная логика только в:
    • createAssignment       — проверка владения классом
    • createDirectAssignment — резолв ученика + class-membership
                                либо teacher_students проверка
    • bulkCreateAssignment   — цикл по class_ids в транзакции

Бонусы:
  - createAssignment (через /classes/:id/assignments) теперь
    поддерживает textbook_slug + textbook_paragraphs (раньше нет —
    скрытый баг, проявлялся бы при попытке назначить чтение через
    UI классов)
  - max_attempts теперь применяется во всех трёх (был только в
    createAssignment)
  - Все три используют stmts.getClass вместо inline db.prepare()

API совместимость не нарушена: схемы тел запросов, return value,
коды статусов идентичны. Existing UI работает без изменений.

Файл: 734 → 744 строки (+10, но дубликат-логика выкинута).
This commit is contained in:
Maxim Dolgolyov
2026-05-16 17:29:22 +03:00
parent d93664946e
commit 2ec59c0fa5
+116 -106
View File
@@ -29,53 +29,121 @@ const stmts = {
notifyClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ? AND user_id != ?'), 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) { SHARED HELPERS for assignment creation
const { title, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body; Used by all 3 endpoints: createAssignment, bulkCreateAssignment,
const mode = req.body.mode || 'exam'; createDirectAssignment. Centralizes validation, resolution and INSERT.
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' });
/**
* 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) { if (test_id) {
const t = stmts.getTestSubject.get(test_id); const t = stmts.getTestSubject.get(Number(test_id));
if (!t) return res.status(400).json({ error: 'Test not found' }); if (!t) return { error: 'Test not found' };
subject_slug = t.subject_slug; 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) { 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; if (f?.subject_slug) subject_slug = f.subject_slug;
} }
// Upload-only homework doesn't require subject // 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'; 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); const cls = stmts.getClass.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Class not found' }); if (!cls) return res.status(404).json({ error: 'Class not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' }); return res.status(403).json({ error: 'Forbidden' });
const r = db.prepare(` const { ok, error } = _resolveAssignment(req.body);
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework, max_attempts) if (error) return res.status(400).json({ error });
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 id = _insertAssignment({ class_id: cls.id }, ok, req.user.id);
const members = stmts.getClassMembers.all(cls.id); _notifyAssignment({ class_id: cls.id }, ok.title);
const notifMsg = `Новое задание: «${cleanTitle}»`; res.status(201).json({ id });
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 ───────────────────────────────── */ /* ── PUT /api/assignments/:id ── update ───────────────────────────────── */
@@ -506,18 +574,9 @@ function assignmentQuestionStats(req, res) {
/* ── POST /api/assignments ── direct assignment to a single student ──────── */ /* ── POST /api/assignments ── direct assignment to a single student ──────── */
function createDirectAssignment(req, res) { function createDirectAssignment(req, res) {
const { deadline, student_email, student_id, file_id, is_homework = 1, const { student_email, student_id, is_homework = 1 } = req.body;
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' });
// Resolve student by id or email
let student; let student;
if (student_id) { if (student_id) {
student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role IN ('student','free_student')").get(Number(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 не найден' }); 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') { if (req.user.role === 'teacher') {
const inClass = db.prepare(` const inClass = db.prepare(`
SELECT 1 FROM class_members cm SELECT 1 FROM class_members cm
@@ -546,37 +605,13 @@ function createDirectAssignment(req, res) {
} }
} }
test_id = test_id ? Number(test_id) : null; // is_homework defaults to 1 for direct (vs 0 for class/bulk)
if (test_id) { const { ok, error } = _resolveAssignment({ is_homework, ...req.body });
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id); if (error) return res.status(400).json({ error });
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 id = _insertAssignment({ user_id: student.id }, ok, req.user.id);
const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id); _notifyAssignment({ user_id: student.id }, ok.title);
if (f?.subject_slug) subject_slug = f.subject_slug; res.status(201).json({ id });
}
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 ── */ /* ── 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 ───── */ /* ── POST /api/assignments/bulk ── assign to multiple classes at once ───── */
function bulkCreateAssignment(req, res) { function bulkCreateAssignment(req, res) {
const { class_ids, title, mode = 'exam', count = 25, topic_id, deadline, test_id, file_id, const { class_ids } = req.body;
is_homework = 0, textbook_slug, textbook_paragraphs } = req.body;
let { subject_slug } = req.body;
if (!Array.isArray(class_ids) || !class_ids.length) if (!Array.isArray(class_ids) || !class_ids.length)
return res.status(400).json({ error: 'class_ids[] required' }); 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 { ok, error } = _resolveAssignment(req.body);
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id); if (error) return res.status(400).json({ error });
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';
// Authorize each class once, then INSERT + notify in a single transaction
const created = db.transaction(() => { const created = db.transaction(() => {
const ids = []; const ids = [];
for (const class_id of class_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 (!cls) continue;
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) continue; if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) continue;
const id = _insertAssignment({ class_id: cls.id }, ok, req.user.id);
const r = db.prepare(` ids.push(id);
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) _notifyAssignment({ class_id: cls.id }, ok.title);
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; return ids;
})(); })();