Files
Learn_System/backend/src/controllers/assignmentController.js
T
Maxim Dolgolyov 2ec59c0fa5 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, но дубликат-логика выкинута).
2026-05-16 17:29:22 +03:00

745 lines
33 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 != ?'),
};
/* ════════════════════════════════════════════════════════════════════
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(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(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 { 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 { ok, error } = _resolveAssignment(req.body);
if (error) return res.status(400).json({ error });
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 ───────────────────────────────── */
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 { 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));
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 не найден' });
}
// 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
JOIN classes c ON c.id = cm.class_id
WHERE cm.user_id = ? AND c.teacher_id = ?
`).get(student.id, req.user.id);
const linked = inClass ? null : db.prepare(
'SELECT 1 FROM teacher_students WHERE teacher_id=? AND student_id=?'
).get(req.user.id, student.id);
if (!inClass && !linked) {
return res.status(403).json({
error: 'Ученик не входит в ваши классы и не добавлен в «Мои ученики». Добавьте его на странице «Мои ученики».',
});
}
}
// 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 });
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 ── */
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 } = req.body;
if (!Array.isArray(class_ids) || !class_ids.length)
return res.status(400).json({ error: 'class_ids[] required' });
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 = stmts.getClass.get(Number(class_id));
if (!cls) continue;
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) continue;
const id = _insertAssignment({ class_id: cls.id }, ok, req.user.id);
ids.push(id);
_notifyAssignment({ class_id: cls.id }, ok.title);
}
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,
};