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>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+559
View File
@@ -0,0 +1,559 @@
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,
};