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>
297 lines
14 KiB
JavaScript
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 };
|