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:
@@ -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;
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user