Files
Learn_System/backend/src/controllers/classController.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
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>
2026-04-12 10:10:37 +03:00

560 lines
24 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 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,
};