Files
Learn_System/backend/src/controllers/assignmentController.js
T
Maxim Dolgolyov 3ff2f01178 feat: textbooks Phase 4 — A1+A2+A3+B4+C7 + назначение ученику
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 + индексы.
2026-05-16 16:37:11 +03:00

728 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};