3ff2f01178
A1 — карточка ДЗ-чтения у ученика на /dashboard: - Новая ветка в buildAssignCard для assignments с textbook_id - Прогресс-бар «X из Y §», цвет берётся из textbook.color - Кнопка «Открыть / Продолжить» с deep-link на первый требуемый параграф - В classify(): textbook_all_read → done, deadline → overdue A2 — авто-проверка выполнения: - При POST /:slug/progress с mark_read: проверяются активные textbook-assignments - Если все требуемые § прочитаны → INSERT в assignment_completion - SSE-уведомление учителю «Ученик завершил чтение: <title>» - myAssignments возвращает completed_at и textbook_all_read A3 — учительский UI прогресса класса: - Новая страница /textbook-progress (учитель/админ) - Селекторы «учебник × класс» → таблица учеников с прогрессом - Сортировка по количеству прочитанного, дата last_at - Кнопка «Прогресс класса» добавлена в /textbooks (видна учителям) B4 — admin-UI управления учебниками: - /admin-textbooks (только admin) — таблица всех учебников - Inline-редактирование title/author, тоггл is_active - Колонка «Читателей» (count из textbook_progress) - Endpoints: GET /api/textbooks/admin/all, PATCH /admin/:id C7 — закладки/заметки внутри учебника: - Таблица textbook_bookmarks (user, textbook, para, text, note, color) - API: GET/POST/PATCH/DELETE для CRUD закладок - В tracker: при выделении текста (8-400 симв) появляется плавающая «+ Закладка» - Кнопка-иконка в overlay top-left открывает панель «Мои закладки» - Хранится paragraph-якорь, цвет, заметка, кнопка удалить Назначение ученику (в дополнение к классу): - В модалке /textbooks — переключатель «Классу / Ученику» - Поиск ученика по имени/email через /api/classes/students - Submit использует POST /api/assignments (createDirectAssignment) - createDirectAssignment расширен textbook_slug + textbook_paragraphs - Учитель может назначать только ученикам своих классов myAssignments расширен: возвращает textbook fields + post-process считает textbook_required_count, textbook_read_count, textbook_all_read. Deep-link поддержка: /textbook/<slug>#pN в tracker.js — на load и hashchange вызывает setParaTab(pN) (нативная функция учебника). Миграция 005: assignment_completion + textbook_bookmarks + индексы.
728 lines
34 KiB
JavaScript
728 lines
34 KiB
JavaScript
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,
|
||
};
|