Files
Learn_System/backend/src/controllers/courseController.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

509 lines
20 KiB
JavaScript

const db = require('../db/db');
/* ── helpers ──────────────────────────────────────────────────────────── */
// Reused SQL fragment: user's completed-lesson count for a course (param: user_id)
const DONE_COUNT_SUBQ = `(SELECT COUNT(*) FROM lesson_progress lp
JOIN lessons l2 ON lp.lesson_id = l2.id
WHERE l2.course_id = c.id AND lp.user_id = ? AND lp.completed = 1) AS done_count`;
function courseRow(row) {
return {
id: row.id,
subjectSlug: row.subject_slug,
title: row.title,
description: row.description || '',
coverEmoji: row.cover_emoji,
orderIndex: row.order_index,
isPublished: row.is_published === 1,
createdBy: row.created_by,
createdAt: row.created_at,
lessonCount: row.lesson_count ?? 0,
doneCount: row.done_count ?? 0,
};
}
function progressSubquery(role) {
return role === 'student' ? 'AND l.is_published = 1' : '';
}
/* ── GET /api/courses ─────────────────────────────────────────────────── */
function list(req, res) {
const { subject } = req.query;
const role = req.user.role;
const uid = req.user.id;
let where = role === 'student' ? 'WHERE c.is_published = 1' : 'WHERE 1=1';
const args = [];
if (subject) { where += ' AND c.subject_slug = ?'; args.push(subject); }
const rows = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c ${where}
ORDER BY c.subject_slug, c.order_index, c.id
`).all(uid, ...args);
res.json(rows.map(courseRow));
}
/* ── GET /api/courses/search?q=… ─────────────────────────────────────── */
function search(req, res) {
const q = (req.query.q || '').trim();
const role = req.user.role;
const uid = req.user.id;
if (!q) return res.json({ courses: [], lessons: [] });
const like = `%${q}%`;
const pubC = role === 'student' ? 'AND c.is_published = 1' : '';
const pubL = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
const courses = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c WHERE (c.title LIKE ? OR c.description LIKE ?) ${pubC}
ORDER BY c.subject_slug, c.order_index LIMIT 20
`).all(uid, like, like).map(courseRow);
const lessons = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug,
lp.completed
FROM lessons l
JOIN courses c ON l.course_id = c.id
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = ?
WHERE l.title LIKE ? ${pubL}
ORDER BY c.subject_slug, l.order_index LIMIT 30
`).all(uid, like);
res.json({ courses, lessons });
}
/* ── GET /api/courses/continue ───────────────────────────────────────── */
// Returns the last-in-progress lesson across all courses
function continueLesson(req, res) {
const uid = req.user.id;
const role = req.user.role;
const pub = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
// 1. last started but not finished lesson (progress row exists, completed=0)
let row = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug, c.cover_emoji
FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
JOIN courses c ON l.course_id = c.id
WHERE lp.user_id = ? AND lp.completed = 0 ${pub}
ORDER BY lp.updated_at DESC LIMIT 1
`).get(uid);
// 2. fallback: first unprogressed lesson in courses that have any progress
if (!row) {
row = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug, c.cover_emoji
FROM lessons l
JOIN courses c ON l.course_id = c.id
WHERE c.id IN (
SELECT DISTINCT l2.course_id FROM lesson_progress lp2
JOIN lessons l2 ON lp2.lesson_id = l2.id WHERE lp2.user_id = ?
)
AND l.id NOT IN (SELECT lesson_id FROM lesson_progress WHERE user_id = ?)
${pub}
ORDER BY l.course_id, l.order_index LIMIT 1
`).get(uid, uid);
}
res.json(row ? {
lessonId: row.id,
lessonTitle: row.title,
courseId: row.course_id,
courseTitle: row.course_title,
subjectSlug: row.subject_slug,
coverEmoji: row.cover_emoji,
} : null);
}
/* ── GET /api/courses/:id ─────────────────────────────────────────────── */
function get(req, res) {
const role = req.user.role;
const uid = req.user.id;
const row = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c WHERE c.id = ?
`).get(uid, req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
if (role === 'student' && !row.is_published)
return res.status(403).json({ error: 'Course not published' });
// sections
const sections = db.prepare(
'SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id'
).all(row.id);
// lessons grouped
const pubWhere = role === 'student' ? 'AND l.is_published = 1' : '';
const lessons = db.prepare(`
SELECT l.id, l.title, l.order_index, l.is_published, l.section_id, l.read_time,
lp.completed
FROM lessons l
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = ?
WHERE l.course_id = ? ${pubWhere}
ORDER BY l.order_index, l.id
`).all(uid, row.id);
res.json({
...courseRow(row),
sections: sections.map(s => ({ id: s.id, title: s.title, orderIndex: s.order_index })),
lessons: lessons.map(l => ({
id: l.id,
title: l.title,
orderIndex: l.order_index,
isPublished: l.is_published === 1,
sectionId: l.section_id,
readTime: l.read_time || 0,
completed: l.completed === 1,
})),
});
}
/* ── GET /api/courses/:id/stats?classId=X ────────────────────────────── */
function stats(req, res) {
const { classId } = req.query;
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
// students in class (or all students who have progress)
let members;
if (classId) {
members = db.prepare(`
SELECT u.id FROM class_members cm JOIN users u ON cm.user_id = u.id
WHERE cm.class_id = ? AND u.role = 'student'
`).all(classId).map(r => r.id);
} else {
members = db.prepare(`
SELECT DISTINCT lp.user_id AS id FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id WHERE l.course_id = ?
`).all(course.id).map(r => r.id);
}
const total = members.length || 1;
const lessons = db.prepare(`
SELECT l.id, l.title, l.order_index,
(SELECT COUNT(*) FROM lesson_progress lp
WHERE lp.lesson_id = l.id AND lp.completed = 1
AND lp.user_id IN (${members.map(() => '?').join(',') || 'NULL'})) AS done_count
FROM lessons l WHERE l.course_id = ? ORDER BY l.order_index, l.id
`).all(...members, course.id);
res.json({ total, lessons: lessons.map(l => ({
id: l.id, title: l.title,
doneCount: l.done_count,
pct: Math.round(l.done_count / total * 100),
}))});
}
/* ── GET /api/courses/:id/analytics?classId=X ───────────────────────── */
function analytics(req, res) {
const { classId } = req.query;
const course = db.prepare('SELECT id, title FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
// all lessons in course
const lessons = db.prepare(
'SELECT id, title, order_index FROM lessons WHERE course_id = ? ORDER BY order_index, id'
).all(course.id);
const lessonIds = lessons.map(l => l.id);
const totalLessons = lessonIds.length;
// students
let students;
if (classId) {
students = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON cm.user_id = u.id
WHERE cm.class_id = ? AND u.role = 'student'
ORDER BY u.name
`).all(classId);
} else {
students = db.prepare(`
SELECT DISTINCT u.id, u.name, u.email FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
JOIN users u ON lp.user_id = u.id
WHERE l.course_id = ?
ORDER BY u.name
`).all(course.id);
}
if (!students.length) {
return res.json({
totalStudents: 0, totalLessons, avgPct: 0,
lessons: lessons.map(l => ({ id: l.id, title: l.title, doneCount: 0, pct: 0 })),
students: [], stuckStudents: [],
});
}
// Batch-fetch ALL progress for ALL students in ONE query (eliminates N+1)
const studentIds = students.map(s => s.id);
const lpRows = lessonIds.length && studentIds.length
? db.prepare(`
SELECT user_id, lesson_id, completed, updated_at FROM lesson_progress
WHERE user_id IN (${studentIds.map(() => '?').join(',')})
AND lesson_id IN (${lessonIds.map(() => '?').join(',')})
`).all(...studentIds, ...lessonIds)
: [];
// Index progress by [user_id][lesson_id]
const progressByUser = {};
for (const r of lpRows) {
if (!progressByUser[r.user_id]) progressByUser[r.user_id] = {};
progressByUser[r.user_id][r.lesson_id] = r;
}
// Per-lesson done count from the same data
const lessonDoneCount = {};
for (const lid of lessonIds) lessonDoneCount[lid] = 0;
for (const r of lpRows) {
if (r.completed === 1) lessonDoneCount[r.lesson_id] = (lessonDoneCount[r.lesson_id] || 0) + 1;
}
const studentData = students.map(s => {
const progressMap = progressByUser[s.id] || {};
const progress = Object.values(progressMap);
const doneCnt = progress.filter(p => p.completed === 1).length;
const pct = totalLessons > 0 ? Math.round(doneCnt / totalLessons * 100) : 0;
// find first incomplete lesson
let firstIncompleteIdx = -1;
for (let i = 0; i < lessons.length; i++) {
const p = progressMap[lessons[i].id];
if (!p || p.completed !== 1) { firstIncompleteIdx = i; break; }
}
// "stuck" = started course (done > 0), not finished, and last activity > 3 days ago
let stuck = false;
let stuckLesson = null;
let lastActivity = null;
if (doneCnt > 0 && doneCnt < totalLessons) {
const dates = progress.map(p => p.updated_at).filter(Boolean);
if (dates.length) {
lastActivity = dates.sort().pop();
const daysSince = (Date.now() - new Date(lastActivity.replace(' ', 'T') + 'Z').getTime()) / 86400000;
if (daysSince > 3) {
stuck = true;
stuckLesson = firstIncompleteIdx >= 0 ? lessons[firstIncompleteIdx] : null;
}
}
}
return {
id: s.id, name: s.name, email: s.email,
doneCount: doneCnt, pct, stuck,
stuckLessonId: stuckLesson?.id || null,
stuckLessonTitle: stuckLesson?.title || null,
lastActivity,
};
});
// Per-lesson stats from pre-computed counts (no extra queries)
const lessonStats = lessons.map(l => {
const doneCount = lessonDoneCount[l.id] || 0;
return {
id: l.id, title: l.title,
doneCount,
pct: students.length > 0 ? Math.round(doneCount / students.length * 100) : 0,
};
});
const avgPct = studentData.length > 0
? Math.round(studentData.reduce((s, d) => s + d.pct, 0) / studentData.length)
: 0;
res.json({
totalStudents: students.length,
totalLessons,
avgPct,
lessons: lessonStats,
students: studentData,
stuckStudents: studentData.filter(s => s.stuck),
});
}
/* ── POST /api/courses ────────────────────────────────────────────────── */
function create(req, res) {
const { subjectSlug, title, description, coverEmoji, orderIndex } = req.body;
if (!subjectSlug || !title)
return res.status(400).json({ error: 'subjectSlug and title required' });
const result = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(subjectSlug, title.trim(), description || null, coverEmoji || '', orderIndex ?? 0, req.user.id);
res.status(201).json({ id: result.lastInsertRowid });
}
/* ── POST /api/courses/:id/duplicate ─────────────────────────────────── */
function duplicate(req, res) {
const src = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id);
if (!src) return res.status(404).json({ error: 'Course not found' });
let newCourseId;
db.transaction(() => {
const cr = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, is_published, created_by)
VALUES (?, ?, ?, ?, ?, 0, ?)
`).run(src.subject_slug, src.title + ' (копия)', src.description, src.cover_emoji, src.order_index, req.user.id);
newCourseId = cr.lastInsertRowid;
// duplicate sections
const secMap = {};
const sections = db.prepare('SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index').all(src.id);
for (const s of sections) {
const sr = db.prepare('INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)').run(newCourseId, s.title, s.order_index);
secMap[s.id] = sr.lastInsertRowid;
}
// duplicate lessons + blocks
const lessons = db.prepare('SELECT * FROM lessons WHERE course_id = ? ORDER BY order_index').all(src.id);
for (const l of lessons) {
const lr = db.prepare(`
INSERT INTO lessons (course_id, title, order_index, section_id, read_time)
VALUES (?, ?, ?, ?, ?)
`).run(newCourseId, l.title, l.order_index, l.section_id ? (secMap[l.section_id] || null) : null, l.read_time || 0);
const newLid = lr.lastInsertRowid;
const blocks = db.prepare('SELECT * FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index').all(l.id);
for (const b of blocks) {
db.prepare('INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)').run(newLid, b.type, b.order_index, b.data);
}
}
})();
res.status(201).json({ id: newCourseId });
}
/* ── PUT /api/courses/:id ─────────────────────────────────────────────── */
function update(req, res) {
const row = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
const { title, description, coverEmoji, orderIndex, isPublished, subjectSlug } = req.body;
db.prepare(`
UPDATE courses SET title=?,description=?,cover_emoji=?,order_index=?,is_published=?,subject_slug=? WHERE id=?
`).run(
title ?? row.title,
description !== undefined ? description : row.description,
coverEmoji ?? row.cover_emoji,
orderIndex ?? row.order_index,
isPublished !== undefined ? (isPublished ? 1 : 0) : row.is_published,
subjectSlug ?? row.subject_slug,
row.id
);
res.json({ ok: true });
}
/* ── DELETE /api/courses/:id ──────────────────────────────────────────── */
function remove(req, res) {
const row = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
db.prepare('DELETE FROM courses WHERE id = ?').run(row.id);
res.json({ ok: true });
}
/* ── SECTIONS ─────────────────────────────────────────────────────────── */
function listSections(req, res) {
const rows = db.prepare('SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id').all(req.params.id);
res.json(rows.map(s => ({ id: s.id, title: s.title, orderIndex: s.order_index })));
}
function createSection(req, res) {
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
const { title, orderIndex } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
const r = db.prepare('INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)').run(course.id, title.trim(), orderIndex ?? 0);
res.status(201).json({ id: r.lastInsertRowid });
}
function updateSection(req, res) {
const s = db.prepare('SELECT * FROM course_sections WHERE id = ? AND course_id = ?').get(req.params.sid, req.params.id);
if (!s) return res.status(404).json({ error: 'Section not found' });
const { title, orderIndex } = req.body;
db.prepare('UPDATE course_sections SET title=?, order_index=? WHERE id=?').run(title ?? s.title, orderIndex ?? s.order_index, s.id);
res.json({ ok: true });
}
function deleteSection(req, res) {
const s = db.prepare('SELECT id FROM course_sections WHERE id = ? AND course_id = ?').get(req.params.sid, req.params.id);
if (!s) return res.status(404).json({ error: 'Section not found' });
// unlink lessons
db.prepare('UPDATE lessons SET section_id = NULL WHERE section_id = ?').run(s.id);
db.prepare('DELETE FROM course_sections WHERE id = ?').run(s.id);
res.json({ ok: true });
}
/* ── CLASS COURSES ────────────────────────────────────────────────────── */
function listClassCourses(req, res) {
const uid = req.user.id;
const role = req.user.role;
const pub = role === 'student' ? 'AND c.is_published = 1' : '';
const rows = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ},
cc.deadline
FROM class_courses cc
JOIN courses c ON cc.course_id = c.id
WHERE cc.class_id = ? ${pub}
ORDER BY cc.assigned_at
`).all(uid, req.params.classId);
res.json(rows.map(r => ({ ...courseRow(r), deadline: r.deadline })));
}
function assignCourseToClass(req, res) {
const { classId } = req.params;
const { courseId, deadline } = req.body;
if (!courseId) return res.status(400).json({ error: 'courseId required' });
try {
db.prepare(`
INSERT INTO class_courses (class_id, course_id, deadline, assigned_by)
VALUES (?, ?, ?, ?)
ON CONFLICT (class_id, course_id) DO UPDATE SET deadline=excluded.deadline
`).run(classId, courseId, deadline || null, req.user.id);
res.json({ ok: true });
} catch (e) { res.status(400).json({ error: e.message }); }
}
function unassignCourseFromClass(req, res) {
db.prepare('DELETE FROM class_courses WHERE class_id = ? AND course_id = ?').run(req.params.classId, req.params.courseId);
res.json({ ok: true });
}
/* ── PATCH /api/courses/:id/publish-all ──────────────────────────────── */
function publishAll(req, res) {
const course = db.prepare('SELECT id, created_by FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
if (req.user.role !== 'admin' && course.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const publish = req.body.publish !== false; // default: publish=true
const { changes } = db.prepare(
'UPDATE lessons SET is_published = ? WHERE course_id = ?'
).run(publish ? 1 : 0, course.id);
// Also publish/unpublish the course itself
db.prepare('UPDATE courses SET is_published = ? WHERE id = ?').run(publish ? 1 : 0, course.id);
res.json({ ok: true, lessonsUpdated: changes });
}
module.exports = {
list, search, continueLesson, get, stats, analytics, create, duplicate, update, remove,
listSections, createSection, updateSection, deleteSection,
listClassCourses, assignCourseToClass, unassignCourseFromClass,
publishAll,
};