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 };