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