be4d43105e
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
560 lines
24 KiB
JavaScript
560 lines
24 KiB
JavaScript
const db = require('../db/db');
|
||
const crypto = require('crypto');
|
||
const { onClassJoined } = require('./gamificationController');
|
||
const { pushNotif } = require('../utils/notifications');
|
||
const { stripTags } = require('../utils/sanitize');
|
||
|
||
function genCode() {
|
||
return crypto.randomBytes(4).toString('hex').toUpperCase();
|
||
}
|
||
|
||
/* ── Prepared statements (module-level to avoid re-parsing per request) ── */
|
||
const stmts = {
|
||
getClassOwner: db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?'),
|
||
getClassWithName: db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?'),
|
||
getClassByCode: db.prepare('SELECT id, name FROM classes WHERE invite_code = ?'),
|
||
checkCodeExists: db.prepare('SELECT id FROM classes WHERE invite_code = ?'),
|
||
getMemberCheck: db.prepare('SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'),
|
||
getClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ?'),
|
||
insertMember: db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)'),
|
||
deleteMember: db.prepare('DELETE FROM class_members WHERE class_id = ? AND user_id = ?'),
|
||
deleteClass: db.prepare('DELETE FROM classes WHERE id = ?'),
|
||
updateClassCode: db.prepare('UPDATE classes SET invite_code = ? WHERE id = ?'),
|
||
updateClassName: db.prepare('UPDATE classes SET name = ? WHERE id = ?'),
|
||
updateClassDesc: db.prepare('UPDATE classes SET description = ? WHERE id = ?'),
|
||
updateClassFeats: db.prepare('UPDATE classes SET features = ? WHERE id = ?'),
|
||
getClassUpdated: db.prepare('SELECT id, name, description, invite_code, features, cover_emoji FROM classes WHERE id = ?'),
|
||
getClassTeacherId: db.prepare('SELECT teacher_id FROM classes WHERE id = ?'),
|
||
getStudentById: db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'"),
|
||
getStudentByEmail: db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'"),
|
||
insertAnnouncement: db.prepare('INSERT INTO announcements (class_id, author_id, text) VALUES (?, ?, ?)'),
|
||
deleteAnnouncement: db.prepare('DELETE FROM announcements WHERE id = ? AND class_id = ?'),
|
||
};
|
||
|
||
/* ── GET /api/classes ── teacher: own classes; admin: all ─────────────── */
|
||
function listClasses(req, res) {
|
||
const { role, id: uid } = req.user;
|
||
const rows = role === 'admin'
|
||
? db.prepare(`
|
||
SELECT c.id, c.name, c.description, c.invite_code, c.cover_emoji, c.created_at,
|
||
u.name AS teacher_name,
|
||
COUNT(DISTINCT cm.user_id) AS member_count,
|
||
COUNT(DISTINCT a.id) AS assignment_count
|
||
FROM classes c
|
||
JOIN users u ON u.id = c.teacher_id
|
||
LEFT JOIN class_members cm ON cm.class_id = c.id
|
||
LEFT JOIN assignments a ON a.class_id = c.id
|
||
GROUP BY c.id ORDER BY c.created_at DESC
|
||
LIMIT 500
|
||
`).all()
|
||
: db.prepare(`
|
||
SELECT c.id, c.name, c.description, c.invite_code, c.cover_emoji, c.created_at,
|
||
COUNT(DISTINCT cm.user_id) AS member_count,
|
||
COUNT(DISTINCT a.id) AS assignment_count
|
||
FROM classes c
|
||
LEFT JOIN class_members cm ON cm.class_id = c.id
|
||
LEFT JOIN assignments a ON a.class_id = c.id
|
||
WHERE c.teacher_id = ?
|
||
GROUP BY c.id ORDER BY c.created_at DESC
|
||
`).all(uid);
|
||
res.json(rows);
|
||
}
|
||
|
||
/* ── POST /api/classes ── create ───────────────────────────────────────── */
|
||
function createClass(req, res) {
|
||
const { name, description, cover_emoji } = req.body;
|
||
if (!name?.trim()) return res.status(400).json({ error: 'name required' });
|
||
let invite_code, attempts = 0;
|
||
while (attempts++ < 10) {
|
||
invite_code = genCode();
|
||
if (!stmts.checkCodeExists.get(invite_code)) break;
|
||
invite_code = null;
|
||
}
|
||
if (!invite_code) return res.status(500).json({ error: 'Не удалось сгенерировать уникальный код — попробуйте ещё раз' });
|
||
const cleanName = stripTags(name.trim());
|
||
const cleanDesc = description ? stripTags(description.trim()) : null;
|
||
const emoji = cover_emoji ? stripTags(String(cover_emoji).trim()).slice(0, 50) : '';
|
||
const r = db.prepare(
|
||
'INSERT INTO classes (name, description, teacher_id, invite_code, cover_emoji) VALUES (?, ?, ?, ?, ?)'
|
||
).run(cleanName, cleanDesc, req.user.id, invite_code, emoji);
|
||
res.status(201).json({ id: r.lastInsertRowid, name: cleanName, invite_code, cover_emoji: emoji });
|
||
}
|
||
|
||
/* ── GET /api/classes/:id ── detail (teacher/admin) ────────────────────── */
|
||
function getClass(req, res) {
|
||
const cls = db.prepare(`
|
||
SELECT c.*, u.name AS teacher_name
|
||
FROM classes c JOIN users u ON u.id = c.teacher_id
|
||
WHERE c.id = ?
|
||
`).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' });
|
||
|
||
if (cls.features) {
|
||
try { cls.features = JSON.parse(cls.features); } catch { cls.features = null; }
|
||
}
|
||
|
||
const members = db.prepare(`
|
||
SELECT u.id, u.name, u.email, cm.joined_at
|
||
FROM class_members cm
|
||
JOIN users u ON u.id = cm.user_id
|
||
WHERE cm.class_id = ?
|
||
ORDER BY cm.joined_at DESC
|
||
`).all(req.params.id);
|
||
|
||
// Load per-member stats in one query instead of N+1 LEFT JOIN
|
||
if (members.length > 0) {
|
||
const uIds = members.map(m => m.id);
|
||
const ph = uIds.map(() => '?').join(',');
|
||
const stats = db.prepare(`
|
||
SELECT user_id,
|
||
COUNT(*) AS tests_count,
|
||
ROUND(AVG(CAST(score AS REAL) / total * 100), 1) AS avg_pct
|
||
FROM test_sessions
|
||
WHERE user_id IN (${ph}) AND status = 'completed'
|
||
GROUP BY user_id
|
||
`).all(...uIds);
|
||
const statsMap = {};
|
||
for (const s of stats) statsMap[s.user_id] = s;
|
||
for (const m of members) {
|
||
m.tests_count = statsMap[m.id]?.tests_count || 0;
|
||
m.avg_pct = statsMap[m.id]?.avg_pct || 0;
|
||
}
|
||
}
|
||
|
||
const assignments = db.prepare(`
|
||
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.test_id, a.max_attempts,
|
||
(SELECT COUNT(*) FROM class_members WHERE class_id = a.class_id) AS total_members,
|
||
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
|
||
FROM assignments a
|
||
LEFT JOIN files f ON f.id = a.file_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.class_id = ?
|
||
GROUP BY a.id ORDER BY a.created_at DESC
|
||
`).all(req.params.id);
|
||
|
||
res.json({ ...cls, members, assignments });
|
||
}
|
||
|
||
/* ── PATCH /api/classes/:id ── rename / update description ─────────────── */
|
||
function updateClass(req, res) {
|
||
const cls = stmts.getClassOwner.get(req.params.id);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
const { name, description, features, cover_emoji } = req.body;
|
||
if (name?.trim()) stmts.updateClassName.run(stripTags(name.trim()), cls.id);
|
||
if (description !== undefined) stmts.updateClassDesc.run(description?.trim() || null, cls.id);
|
||
if (cover_emoji !== undefined) {
|
||
const emoji = cover_emoji ? stripTags(String(cover_emoji).trim()).slice(0, 50) : '';
|
||
db.prepare('UPDATE classes SET cover_emoji = ? WHERE id = ?').run(emoji, cls.id);
|
||
}
|
||
if (features !== undefined) stmts.updateClassFeats.run(features !== null ? JSON.stringify(features) : null, cls.id);
|
||
const updated = stmts.getClassUpdated.get(cls.id);
|
||
if (updated.features) {
|
||
try { updated.features = JSON.parse(updated.features); } catch { updated.features = null; }
|
||
}
|
||
res.json(updated);
|
||
}
|
||
|
||
/* ── POST /api/classes/:id/new-code ── regenerate invite code ───────────── */
|
||
function regenerateCode(req, res) {
|
||
const cls = stmts.getClassOwner.get(req.params.id);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
let code, attempts = 0;
|
||
while (attempts++ < 10) {
|
||
code = genCode();
|
||
if (!stmts.checkCodeExists.get(code)) break;
|
||
code = null;
|
||
}
|
||
if (!code) return res.status(500).json({ error: 'Не удалось сгенерировать уникальный код — попробуйте ещё раз' });
|
||
stmts.updateClassCode.run(code, cls.id);
|
||
res.json({ invite_code: code });
|
||
}
|
||
|
||
/* ── GET /api/classes/:id/journal ── grade matrix for teacher ────────────── */
|
||
function classJournal(req, res) {
|
||
const cls = stmts.getClassWithName.get(req.params.id);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
|
||
const members = db.prepare(`
|
||
SELECT u.id, u.name, u.email FROM class_members cm
|
||
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
|
||
`).all(req.params.id);
|
||
|
||
const assignments = db.prepare(`
|
||
SELECT id, title, subject_slug, deadline, is_homework, created_at
|
||
FROM assignments WHERE class_id = ? AND user_id IS NULL ORDER BY created_at ASC
|
||
`).all(req.params.id);
|
||
|
||
const results = db.prepare(`
|
||
SELECT ases.user_id, ases.assignment_id,
|
||
MAX(ts.score) AS score,
|
||
ts.total,
|
||
MAX(ROUND(CAST(ts.score AS REAL) / ts.total * 100)) AS percent,
|
||
MAX(ts.finished_at) AS finished_at
|
||
FROM assignment_sessions ases
|
||
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
|
||
JOIN assignments a ON a.id = ases.assignment_id
|
||
WHERE a.class_id = ? AND a.user_id IS NULL
|
||
GROUP BY ases.user_id, ases.assignment_id
|
||
`).all(req.params.id);
|
||
|
||
// course progress per student
|
||
const courses = db.prepare(`
|
||
SELECT c.id, c.title, c.subject_slug,
|
||
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id AND l.is_published = 1) AS lesson_count
|
||
FROM class_courses cc
|
||
JOIN courses c ON cc.course_id = c.id
|
||
WHERE cc.class_id = ?
|
||
ORDER BY cc.assigned_at
|
||
`).all(req.params.id);
|
||
|
||
let courseProgress = [];
|
||
if (courses.length && members.length) {
|
||
const cIds = courses.map(c => c.id);
|
||
const mIds = members.map(m => m.id);
|
||
const cPh = cIds.map(() => '?').join(',');
|
||
const mPh = mIds.map(() => '?').join(',');
|
||
const rows = db.prepare(`
|
||
SELECT l.course_id, lp.user_id, COUNT(*) AS done_count
|
||
FROM lesson_progress lp
|
||
JOIN lessons l ON lp.lesson_id = l.id
|
||
WHERE l.course_id IN (${cPh}) AND lp.user_id IN (${mPh}) AND lp.completed = 1
|
||
GROUP BY l.course_id, lp.user_id
|
||
`).all(...cIds, ...mIds);
|
||
const doneMap = {};
|
||
for (const r of rows) doneMap[r.user_id + '_' + r.course_id] = r.done_count;
|
||
for (const c of courses) {
|
||
for (const m of members) {
|
||
const done = doneMap[m.id + '_' + c.id] || 0;
|
||
courseProgress.push({
|
||
userId: m.id, courseId: c.id,
|
||
doneCount: done, totalLessons: c.lesson_count,
|
||
percent: c.lesson_count > 0 ? Math.round(done / c.lesson_count * 100) : 0,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// averages per student — O(n) via Map instead of O(n*m) filter
|
||
const resultsByUser = new Map();
|
||
for (const r of results) {
|
||
if (!resultsByUser.has(r.user_id)) resultsByUser.set(r.user_id, []);
|
||
resultsByUser.get(r.user_id).push(r);
|
||
}
|
||
const studentStats = members.map(m => {
|
||
const studentResults = resultsByUser.get(m.id) || [];
|
||
const avgPct = studentResults.length > 0
|
||
? Math.round(studentResults.reduce((s, r) => s + r.percent, 0) / studentResults.length)
|
||
: null;
|
||
return { userId: m.id, avgPct, completedCount: studentResults.length, totalAssignments: assignments.length };
|
||
});
|
||
|
||
res.json({ className: cls.name, members, assignments, results, courses, courseProgress, studentStats });
|
||
}
|
||
|
||
/* ── GET /api/classes/:id/journal/csv ── export gradebook as CSV ────────── */
|
||
function classJournalCsv(req, res) {
|
||
const cls = stmts.getClassWithName.get(req.params.id);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
|
||
const members = db.prepare(`
|
||
SELECT u.id, u.name, u.email FROM class_members cm
|
||
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
|
||
`).all(req.params.id);
|
||
|
||
const assignments = db.prepare(`
|
||
SELECT id, title FROM assignments WHERE class_id = ? AND user_id IS NULL ORDER BY created_at ASC
|
||
`).all(req.params.id);
|
||
|
||
const results = db.prepare(`
|
||
SELECT ases.user_id, ases.assignment_id,
|
||
MAX(ROUND(CAST(ts.score AS REAL) / ts.total * 100)) AS percent
|
||
FROM assignment_sessions ases
|
||
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
|
||
JOIN assignments a ON a.id = ases.assignment_id
|
||
WHERE a.class_id = ? AND a.user_id IS NULL
|
||
GROUP BY ases.user_id, ases.assignment_id
|
||
`).all(req.params.id);
|
||
|
||
// Build result map
|
||
const rmap = {};
|
||
results.forEach(r => { rmap[r.user_id + '_' + r.assignment_id] = r.percent; });
|
||
|
||
// CSV header
|
||
// Protect against CSV formula injection (=, +, @, - at start trigger Excel/Sheets formulas)
|
||
const csvEsc = s => {
|
||
const str = String(s || '');
|
||
const safe = /^[=+@\-]/.test(str) ? `'${str}` : str;
|
||
return '"' + safe.replace(/"/g, '""') + '"';
|
||
};
|
||
const header = ['Ученик', 'Email', ...assignments.map(a => a.title), 'Средний %'];
|
||
const rows = [header.map(csvEsc).join(',')];
|
||
|
||
members.forEach(m => {
|
||
const scores = assignments.map(a => {
|
||
const key = m.id + '_' + a.id;
|
||
return rmap[key] !== undefined ? rmap[key] : '';
|
||
});
|
||
const filled = scores.filter(s => s !== '');
|
||
const avg = filled.length > 0 ? Math.round(filled.reduce((s, v) => s + v, 0) / filled.length) : '';
|
||
rows.push([csvEsc(m.name), csvEsc(m.email), ...scores, avg].join(','));
|
||
});
|
||
|
||
const bom = '\uFEFF'; // UTF-8 BOM for Excel
|
||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||
res.setHeader('Content-Disposition', `attachment; filename="gradebook-${cls.name.replace(/[^a-zA-Zа-яА-Я0-9]/g, '_')}.csv"`);
|
||
res.send(bom + rows.join('\n'));
|
||
}
|
||
|
||
/* ── DELETE /api/classes/:id ──────────────────────────────────────────── */
|
||
function deleteClass(req, res) {
|
||
const cls = stmts.getClassOwner.get(req.params.id);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
stmts.deleteClass.run(req.params.id);
|
||
res.json({ ok: true });
|
||
}
|
||
|
||
/* ── POST /api/classes/join ── student joins by invite code ────────────── */
|
||
function joinClass(req, res) {
|
||
const { invite_code } = req.body;
|
||
const cls = stmts.getClassByCode.get(invite_code?.trim().toUpperCase());
|
||
if (!cls) return res.status(404).json({ error: 'Неверный код приглашения' });
|
||
try {
|
||
stmts.insertMember.run(cls.id, req.user.id);
|
||
} catch (e) {
|
||
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Вы уже в этом классе' });
|
||
throw e;
|
||
}
|
||
// Notify teacher about new student joining
|
||
try {
|
||
const teacher = stmts.getClassTeacherId.get(cls.id);
|
||
if (teacher) pushNotif(teacher.teacher_id, 'join', `«${req.user.name}» вступил в класс «${cls.name}»`, '/classes');
|
||
} catch {}
|
||
try { onClassJoined(req.user.id); } catch {}
|
||
res.json({ ok: true, class_name: cls.name });
|
||
}
|
||
|
||
/* ── GET /api/classes/students ── list students in teacher's classes ─ */
|
||
function listStudents(req, res) {
|
||
if (req.user.role === 'admin') {
|
||
const rows = db.prepare(
|
||
"SELECT id, name, email FROM users WHERE role IN ('student','free_student') ORDER BY name"
|
||
).all();
|
||
return res.json(rows);
|
||
}
|
||
// Teacher: only students in their classes
|
||
const rows = db.prepare(`
|
||
SELECT DISTINCT u.id, u.name, u.email FROM users u
|
||
JOIN class_members cm ON cm.user_id = u.id
|
||
JOIN classes c ON c.id = cm.class_id
|
||
WHERE c.teacher_id = ? AND u.role IN ('student','free_student')
|
||
ORDER BY u.name
|
||
`).all(req.user.id);
|
||
res.json(rows);
|
||
}
|
||
|
||
/* ── POST /api/classes/:id/members ── admin/teacher adds student by email or id ─ */
|
||
function addMember(req, res) {
|
||
const cls = stmts.getClassOwner.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 { email, user_id } = req.body;
|
||
let student;
|
||
if (user_id) {
|
||
student = stmts.getStudentById.get(Number(user_id));
|
||
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
|
||
} else {
|
||
if (!email?.trim()) return res.status(400).json({ error: 'email required' });
|
||
student = stmts.getStudentByEmail.get(email.trim().toLowerCase());
|
||
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
|
||
}
|
||
|
||
try {
|
||
stmts.insertMember.run(cls.id, student.id);
|
||
} catch (e) {
|
||
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Ученик уже в этом классе' });
|
||
throw e;
|
||
}
|
||
res.json({ ok: true, name: student.name });
|
||
}
|
||
|
||
/* ── DELETE /api/classes/:id/members/:uid ── kick ─────────────────────── */
|
||
function kickMember(req, res) {
|
||
const cls = stmts.getClassOwner.get(req.params.id);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
stmts.deleteMember.run(req.params.id, req.params.uid);
|
||
res.json({ ok: true });
|
||
}
|
||
|
||
/* ── GET /api/classes/my ── student's enrolled classes ─────────────────── */
|
||
function myClasses(req, res) {
|
||
const classes = db.prepare(`
|
||
SELECT c.id, c.name, c.description, u.name AS teacher_name,
|
||
COUNT(DISTINCT a.id) AS assignment_count
|
||
FROM class_members cm
|
||
JOIN classes c ON c.id = cm.class_id
|
||
JOIN users u ON u.id = c.teacher_id
|
||
LEFT JOIN assignments a ON a.class_id = c.id
|
||
WHERE cm.user_id = ?
|
||
GROUP BY c.id ORDER BY cm.joined_at DESC
|
||
`).all(req.user.id);
|
||
res.json(classes);
|
||
}
|
||
|
||
/* ── GET /api/classes/:id/announcements ─────────────────────────────────── */
|
||
function getAnnouncements(req, res) {
|
||
const announcements = db.prepare(`
|
||
SELECT a.id, a.text, a.created_at, u.name AS author_name
|
||
FROM announcements a
|
||
JOIN users u ON u.id = a.author_id
|
||
WHERE a.class_id = ?
|
||
ORDER BY a.created_at DESC LIMIT 50
|
||
`).all(req.params.id);
|
||
res.json(announcements);
|
||
}
|
||
|
||
/* ── POST /api/classes/:id/announcements ────────────────────────────────── */
|
||
function createAnnouncement(req, res) {
|
||
const text = stripTags(req.body.text || '');
|
||
if (!text) return res.status(400).json({ error: 'text required' });
|
||
if (text.length > 4000) return res.status(400).json({ error: 'text too long (max 4000 chars)' });
|
||
const cls = stmts.getClassWithName.get(req.params.id);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
|
||
const r = stmts.insertAnnouncement.run(req.params.id, req.user.id, text);
|
||
|
||
const members = stmts.getClassMembers.all(req.params.id);
|
||
const preview = text.slice(0, 80) + (text.length > 80 ? '…' : '');
|
||
const batchNotif = db.transaction(() => {
|
||
for (const m of members) {
|
||
pushNotif(m.user_id, 'announcement', `Объявление в «${cls.name}»: ${preview}`, '/classes');
|
||
}
|
||
});
|
||
batchNotif();
|
||
res.status(201).json({ id: r.lastInsertRowid });
|
||
}
|
||
|
||
/* ── GET /api/classes/:id/feed ── class board (students + teacher) ──────── */
|
||
function classFeed(req, res) {
|
||
const { role, id: uid } = req.user;
|
||
const classId = req.params.id;
|
||
|
||
const cls = stmts.getClassWithName.get(classId);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
|
||
const isTeacherOrAdmin = role === 'admin' || cls.teacher_id === uid;
|
||
if (!isTeacherOrAdmin) {
|
||
const member = stmts.getMemberCheck.get(classId, uid);
|
||
if (!member) return res.status(403).json({ error: 'Forbidden' });
|
||
}
|
||
|
||
const assignments = db.prepare(`
|
||
SELECT a.id, a.title, a.subject_slug, a.mode, a.deadline, a.created_at, a.is_homework,
|
||
a.max_attempts,
|
||
f.title AS file_title,
|
||
COUNT(DISTINCT cm.user_id) AS total_members,
|
||
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS done_count
|
||
FROM assignments a
|
||
LEFT JOIN files f ON f.id = a.file_id
|
||
LEFT JOIN class_members cm ON cm.class_id = a.class_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.class_id = ?
|
||
GROUP BY a.id
|
||
ORDER BY a.created_at DESC LIMIT 20
|
||
`).all(classId);
|
||
|
||
if (!isTeacherOrAdmin && assignments.length) {
|
||
const aIds = assignments.map(a => a.id);
|
||
const ph = aIds.map(() => '?').join(',');
|
||
|
||
// Latest session per assignment
|
||
const latest = db.prepare(`
|
||
SELECT ases.assignment_id, ts.score, ts.total, ts.status
|
||
FROM assignment_sessions ases
|
||
JOIN test_sessions ts ON ts.id = ases.session_id
|
||
WHERE ases.assignment_id IN (${ph}) AND ases.user_id = ?
|
||
AND ases.id = (SELECT MAX(a2.id) FROM assignment_sessions a2
|
||
WHERE a2.assignment_id = ases.assignment_id AND a2.user_id = ases.user_id)
|
||
`).all(...aIds, uid);
|
||
const latestMap = {};
|
||
for (const r of latest) latestMap[r.assignment_id] = r;
|
||
|
||
// Completed attempts count per assignment
|
||
const attempts = db.prepare(`
|
||
SELECT ases.assignment_id, COUNT(*) AS n
|
||
FROM assignment_sessions ases
|
||
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
|
||
WHERE ases.assignment_id IN (${ph}) AND ases.user_id = ?
|
||
GROUP BY ases.assignment_id
|
||
`).all(...aIds, uid);
|
||
const attemptsMap = {};
|
||
for (const r of attempts) attemptsMap[r.assignment_id] = r.n;
|
||
|
||
for (const a of assignments) {
|
||
const ses = latestMap[a.id];
|
||
a.my_status = ses ? ses.status : null;
|
||
a.my_score = ses?.score ?? null;
|
||
a.my_total = ses?.total ?? null;
|
||
a.attempts_used = attemptsMap[a.id] || 0;
|
||
}
|
||
}
|
||
|
||
const announcements = db.prepare(`
|
||
SELECT a.id, a.text, a.created_at, u.name AS author_name
|
||
FROM announcements a
|
||
JOIN users u ON u.id = a.author_id
|
||
WHERE a.class_id = ?
|
||
ORDER BY a.created_at DESC LIMIT 20
|
||
`).all(classId);
|
||
|
||
const activity = db.prepare(`
|
||
SELECT u.name AS student_name, a.title AS assignment_title,
|
||
ts.score, ts.total, ts.finished_at AS completed_at
|
||
FROM test_sessions ts
|
||
JOIN assignment_sessions ases ON ases.session_id = ts.id
|
||
JOIN assignments a ON a.id = ases.assignment_id
|
||
JOIN users u ON u.id = ts.user_id
|
||
WHERE a.class_id = ? AND ts.status = 'completed'
|
||
ORDER BY ts.finished_at DESC LIMIT 15
|
||
`).all(classId);
|
||
|
||
res.json({ class: cls, assignments, announcements, activity });
|
||
}
|
||
|
||
/* ── DELETE /api/classes/:id/announcements/:aid ─────────────────────────── */
|
||
function deleteAnnouncement(req, res) {
|
||
const cls = stmts.getClassOwner.get(req.params.id);
|
||
if (!cls) return res.status(404).json({ error: 'Not found' });
|
||
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
|
||
return res.status(403).json({ error: 'Forbidden' });
|
||
stmts.deleteAnnouncement.run(req.params.aid, req.params.id);
|
||
res.json({ ok: true });
|
||
}
|
||
|
||
module.exports = {
|
||
listClasses, createClass, getClass, deleteClass,
|
||
joinClass, kickMember, addMember, myClasses, listStudents,
|
||
getAnnouncements, createAnnouncement, deleteAnnouncement, classFeed,
|
||
updateClass, regenerateCode, classJournal, classJournalCsv,
|
||
};
|