Files
Learn_System/backend/src/controllers/lessonController.js
T
Maxim Dolgolyov dd5dfee5c9 fix(anti-cheat): анти-фарм XP в играх и при повторном завершении урока (Спринт1 #2,#3)
- games: дневной лимит начислений XP за hangman/crossword (DAILY_WIN_CAP=10,
  счёт по xp_log.reason) — нельзя бесконечно фармить циклом complete.
- lessons.markComplete: XP/монеты только при ПЕРВОМ завершении урока
  (повторные POST больше ничего не начисляют).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:54:41 +03:00

336 lines
16 KiB
JavaScript

const db = require('../db/db');
const { onLessonComplete } = require('./gamificationController');
const { stripTags } = require('../utils/sanitize');
// Shared whitelist SVG sanitizer (UMD module, node-safe) — single source of
// truth with the client. In node it uses the conservative regex path.
const { clean: cleanSvg } = require('../../../frontend/js/svg-sanitize.js');
/* ── 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' });
const course = db.prepare('SELECT id, created_by FROM courses WHERE id = ?').get(courseId);
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 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','svg-draw','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';
let data = b.data || {};
// Sanitize inline SVG drawings server-side (defense-in-depth).
if (type === 'svg-draw' && data && typeof data.svg === 'string') {
data = Object.assign({}, data, { svg: cleanSvg(data.svg) });
}
ins.run(lesson.id, type, b.orderIndex ?? i, JSON.stringify(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' });
// Награду даём только при ПЕРВОМ завершении (анти-фарм повторными POST).
const prev = db.prepare('SELECT completed FROM lesson_progress WHERE user_id = ? AND lesson_id = ?').get(req.user.id, lesson.id);
const firstCompletion = !prev || prev.completed !== 1;
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;
if (firstCompletion) { 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 = stripTags((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 });
}
/* ── POST /api/lessons/quick ──────────────────────────────────────────────
Create a standalone "quick lesson" without manually building a course:
reuse (or lazily create) the teacher's hidden personal container course,
add one lesson to it, and return its id for the editor. */
function quickLesson(req, res) {
const uid = req.user.id;
let container = db.prepare(
'SELECT id FROM courses WHERE created_by = ? AND is_personal = 1 ORDER BY id LIMIT 1'
).get(uid);
if (!container) {
const r = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, is_published, is_personal, created_by)
VALUES ('personal', 'Мои материалы', 'Отдельные уроки без курса', '', 0, 1, 1, ?)
`).run(uid);
container = { id: Number(r.lastInsertRowid) };
}
const title = (req.body && req.body.title && String(req.body.title).trim()) || 'Новый урок';
const n = db.prepare('SELECT COUNT(*) AS c FROM lessons WHERE course_id = ?').get(container.id).c;
const r2 = db.prepare(
'INSERT INTO lessons (course_id, title, order_index) VALUES (?, ?, ?)'
).run(container.id, title, n);
res.status(201).json({ lessonId: Number(r2.lastInsertRowid), courseId: container.id });
}
module.exports = { get, create, update, remove, saveBlocks, markComplete, saveNote, listComments, addComment, deleteComment, quickLesson };