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

297 lines
14 KiB
JavaScript

const db = require('../db/db');
const { onLessonComplete } = require('./gamificationController');
/* ── helpers ──────────────────────────────────────────────────────────── */
function parseBlock(b) {
let data = {};
try { data = JSON.parse(b.data); } catch {}
return { id: b.id, type: b.type, orderIndex: b.order_index, data };
}
// Estimate read time from blocks (words / 200 wpm)
// Accepts blocks with data already parsed as objects (not JSON strings)
function calcReadTime(blocks) {
let words = 0;
for (const b of blocks) {
const d = (typeof b.data === 'string') ? (() => { try { return JSON.parse(b.data); } catch { return {}; } })() : (b.data || {});
if (b.type === 'text') words += (d.html || d.text || '').replace(/<[^>]+>/g, '').split(/\s+/).filter(Boolean).length;
if (b.type === 'heading') words += (d.text || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'quiz') words += 8;
if (b.type === 'accordion') words += (d.content || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'timeline' && Array.isArray(d.items))
for (const it of d.items) words += ((it.title||'') + ' ' + (it.text||'')).split(/\s+/).filter(Boolean).length;
if (b.type === 'geogebra' || b.type === 'diagram') words += 15;
if (b.type === 'audio') words += 30;
if (b.type === 'video') words += 30;
if (b.type === 'alert') words += (d.text || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'columns' && Array.isArray(d.cols))
for (const col of d.cols) words += (col.content || '').replace(/<[^>]+>/g, '').split(/\s+/).filter(Boolean).length;
}
return Math.max(1, Math.ceil(words / 200));
}
/* ── GET /api/lessons/:id ─────────────────────────────────────────────── */
function get(req, res) {
const role = req.user.role;
const uid = req.user.id;
const row = db.prepare(`
SELECT l.*, c.title AS course_title, c.is_published AS course_published
FROM lessons l JOIN courses c ON c.id = l.course_id
WHERE l.id = ?
`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'Lesson not found' });
if (role === 'student' && !row.is_published)
return res.status(403).json({ error: 'Lesson not published' });
if (role === 'student' && !row.course_published)
return res.status(403).json({ error: 'Course not published' });
const lesson = row;
const course = { title: row.course_title };
const blocks = db.prepare(
'SELECT id, type, order_index, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(lesson.id).map(parseBlock);
const progress = db.prepare(
'SELECT completed FROM lesson_progress WHERE user_id = ? AND lesson_id = ?'
).get(uid, lesson.id);
const note = db.prepare(
'SELECT text FROM lesson_notes WHERE user_id = ? AND lesson_id = ?'
).get(uid, lesson.id);
// adjacent lessons
const pubWhere = role === 'student' ? 'AND is_published = 1' : '';
const siblings = db.prepare(`
SELECT id, title FROM lessons WHERE course_id = ? ${pubWhere} ORDER BY order_index, id
`).all(lesson.course_id);
const idx = siblings.findIndex(s => s.id === lesson.id);
const prev = idx > 0 ? siblings[idx - 1] : null;
const next = idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1] : null;
res.json({
id: lesson.id,
courseId: lesson.course_id,
courseTitle: course.title,
title: lesson.title,
orderIndex: lesson.order_index,
isPublished: lesson.is_published === 1,
sectionId: lesson.section_id,
readTime: lesson.read_time || 0,
blocks,
completed: progress?.completed === 1,
note: note?.text || '',
prev: prev ? { id: prev.id, title: prev.title } : null,
next: next ? { id: next.id, title: next.title } : null,
});
}
/* ── POST /api/lessons ────────────────────────────────────────────────── */
function create(req, res) {
const { courseId, title, orderIndex, sectionId } = req.body;
if (!courseId || !title)
return res.status(400).json({ error: 'courseId and title required' });
if (!db.prepare('SELECT id FROM courses WHERE id = ?').get(courseId))
return res.status(404).json({ error: 'Course not found' });
const r = db.prepare(
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
).run(courseId, title.trim(), orderIndex ?? 0, sectionId || null);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── PUT /api/lessons/:id ─────────────────────────────────────────────── */
function update(req, res) {
const lesson = db.prepare(`
SELECT l.*, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { title, orderIndex, isPublished, sectionId } = req.body;
db.prepare(`
UPDATE lessons SET title=?, order_index=?, is_published=?, section_id=? WHERE id=?
`).run(
title ?? lesson.title,
orderIndex ?? lesson.order_index,
isPublished !== undefined ? (isPublished ? 1 : 0) : lesson.is_published,
sectionId !== undefined ? (sectionId || null) : lesson.section_id,
lesson.id
);
res.json({ ok: true });
}
/* ── DELETE /api/lessons/:id ──────────────────────────────────────────── */
function remove(req, res) {
const lesson = db.prepare(`
SELECT l.id, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM lessons WHERE id = ?').run(lesson.id);
res.json({ ok: true });
}
/* ── PUT /api/lessons/:id/blocks ──────────────────────────────────────── */
function saveBlocks(req, res) {
const lesson = db.prepare(`
SELECT l.id, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id
WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const blocks = req.body.blocks;
if (!Array.isArray(blocks))
return res.status(400).json({ error: 'blocks must be an array' });
const VALID_TYPES = ['heading','text','formula','image','quiz','sim','table','code','divider','callout','video','flashcard','matching','fill-blank','ordering','accordion','timeline','diagram','geogebra','audio','columns','alert'];
db.transaction(() => {
db.prepare('DELETE FROM lesson_blocks WHERE lesson_id = ?').run(lesson.id);
const ins = db.prepare(
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
);
blocks.forEach((b, i) => {
const type = VALID_TYPES.includes(b.type) ? b.type : 'text';
ins.run(lesson.id, type, b.orderIndex ?? i, JSON.stringify(b.data || {}));
});
// recalculate read time — pass already-parsed data objects, no double stringify/parse
const rt = calcReadTime(blocks);
db.prepare('UPDATE lessons SET read_time = ? WHERE id = ?').run(rt, lesson.id);
})();
res.json({ ok: true, count: blocks.length });
}
/* ── POST /api/lessons/:id/complete ──────────────────────────────────── */
function markComplete(req, res) {
const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
db.prepare(`
INSERT INTO lesson_progress (user_id, lesson_id, completed, updated_at)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT (user_id, lesson_id) DO UPDATE SET completed=1, updated_at=datetime('now')
`).run(req.user.id, lesson.id);
const total = db.prepare(
'SELECT COUNT(*) AS n FROM lessons WHERE course_id = ? AND is_published = 1'
).get(lesson.course_id).n;
const done = db.prepare(`
SELECT COUNT(*) AS n FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
WHERE l.course_id = ? AND lp.user_id = ? AND lp.completed = 1
`).get(lesson.course_id, req.user.id).n;
try { onLessonComplete(req.user.id, lesson.course_id); } catch {}
res.json({ ok: true, courseComplete: done >= total && total > 0 });
}
/* ── PUT /api/lessons/:id/note ────────────────────────────────────────── */
function saveNote(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const text = (req.body.text || '').slice(0, 5000);
db.prepare(`
INSERT INTO lesson_notes (user_id, lesson_id, text, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (user_id, lesson_id) DO UPDATE SET text=excluded.text, updated_at=datetime('now')
`).run(req.user.id, lesson.id, text);
res.json({ ok: true });
}
/* ── GET /api/lessons/:id/comments ──────────────────────────────────── */
function listComments(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const rows = db.prepare(`
SELECT c.id, c.lesson_id, c.user_id, c.parent_id, c.text, c.created_at,
u.name AS user_name, u.role AS user_role
FROM lesson_comments c
JOIN users u ON c.user_id = u.id
WHERE c.lesson_id = ?
ORDER BY c.created_at ASC
`).all(lesson.id);
// build threaded structure: top-level + replies
const top = [];
const byId = {};
rows.forEach(r => {
const item = {
id: r.id, lessonId: r.lesson_id, userId: r.user_id,
parentId: r.parent_id, text: r.text, createdAt: r.created_at,
userName: r.user_name, userRole: r.user_role,
replies: [],
};
byId[r.id] = item;
if (!r.parent_id) {
top.push(item);
} else if (byId[r.parent_id]) {
byId[r.parent_id].replies.push(item);
} else {
top.push(item); // orphan → treat as top-level
}
});
res.json(top);
}
/* ── POST /api/lessons/:id/comments ────────────────────────────────── */
function addComment(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const text = (req.body.text || '').trim();
if (!text) return res.status(400).json({ error: 'text required' });
if (text.length > 2000) return res.status(400).json({ error: 'Comment too long (max 2000)' });
const parentId = req.body.parentId || null;
if (parentId) {
const parent = db.prepare('SELECT id FROM lesson_comments WHERE id = ? AND lesson_id = ?').get(parentId, lesson.id);
if (!parent) return res.status(400).json({ error: 'Parent comment not found' });
}
const r = db.prepare(
'INSERT INTO lesson_comments (lesson_id, user_id, parent_id, text) VALUES (?, ?, ?, ?)'
).run(lesson.id, req.user.id, parentId, text);
// notify lesson author / teacher if comment is from student
try {
const course = db.prepare('SELECT created_by FROM courses WHERE id = (SELECT course_id FROM lessons WHERE id = ?)').get(lesson.id);
if (course && course.created_by !== req.user.id) {
const userName = db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id)?.name || 'Ученик';
const lessonTitle = db.prepare('SELECT title FROM lessons WHERE id = ?').get(lesson.id)?.title || 'Урок';
db.prepare(
'INSERT INTO notifications (user_id, type, message, link) VALUES (?, ?, ?, ?)'
).run(course.created_by, 'comment', `${userName} оставил(а) комментарий к уроку «${lessonTitle}»`,
`/lesson?id=${lesson.id}#comments`);
}
} catch {}
res.status(201).json({ id: Number(r.lastInsertRowid) });
}
/* ── DELETE /api/lessons/:id/comments/:cid ─────────────────────────── */
function deleteComment(req, res) {
const comment = db.prepare('SELECT * FROM lesson_comments WHERE id = ? AND lesson_id = ?').get(req.params.cid, req.params.id);
if (!comment) return res.status(404).json({ error: 'Comment not found' });
// only author or teacher/admin can delete
const isOwner = comment.user_id === req.user.id;
const isPrivileged = ['teacher', 'admin'].includes(req.user.role);
if (!isOwner && !isPrivileged) return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM lesson_comments WHERE id = ?').run(comment.id);
res.json({ ok: true });
}
module.exports = { get, create, update, remove, saveBlocks, markComplete, saveNote, listComments, addComment, deleteComment };