const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const db = require('../db/db'); const { JWT_SECRET } = require('../config'); /* ── Prepared statements ────────────────────────────────────────────── */ const stmts = { linkByToken: db.prepare('SELECT * FROM parent_links WHERE token = ?'), linkById: db.prepare('SELECT * FROM parent_links WHERE id = ?'), linksByStudent: db.prepare('SELECT id, token, label, is_active, last_used, created_at, expires_at FROM parent_links WHERE student_id = ? ORDER BY created_at DESC'), linkCount: db.prepare('SELECT COUNT(*) AS cnt FROM parent_links WHERE student_id = ?'), insertLink: db.prepare('INSERT INTO parent_links (student_id, token, label) VALUES (?, ?, ?)'), updateLink: db.prepare('UPDATE parent_links SET label = ?, is_active = ? WHERE id = ?'), deleteLink: db.prepare('DELETE FROM parent_links WHERE id = ?'), updateLastUsed: db.prepare("UPDATE parent_links SET last_used = datetime('now') WHERE id = ?"), /* Mega CTE: student info + stats + heatmap + weekly + recent activity in ONE query */ dashboardMega: db.prepare(` WITH base AS ( SELECT ts.id, ts.score, ts.total, ts.started_at, ts.finished_at, s.slug AS subject_slug, s.name AS subject_name FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id WHERE ts.user_id = @uid AND ts.status = 'completed' ), hm AS ( SELECT date(ts.started_at) AS day, COUNT(*) AS cnt FROM test_sessions ts WHERE ts.user_id = @uid AND ts.started_at >= date('now', '-90 days') GROUP BY day ORDER BY day ), week_activity AS ( SELECT COUNT(*) AS cnt, AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct FROM base WHERE started_at >= date('now', 'weekday 0', '-7 days') ) SELECT (SELECT json_object('name',u.name,'xp',u.xp,'level',u.level, 'streak_current',u.streak_current,'streak_best',u.streak_best,'coins',u.coins) FROM users u WHERE u.id = @uid) AS student, (SELECT COALESCE(json_group_array(json_object( 'week', week, 'sessions', sessions, 'avg_pct', avg_pct)), '[]') FROM (SELECT strftime('%Y-%W', finished_at) AS week, COUNT(*) AS sessions, AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct FROM base WHERE finished_at >= date('now', '-56 days') GROUP BY week ORDER BY week)) AS weekly, (SELECT COALESCE(json_group_array(json_object('day', day, 'count', cnt)), '[]') FROM hm) AS heatmap, (SELECT COALESCE(json_group_array(json_object( 'slug', subject_slug, 'name', subject_name, 'sessions', sessions, 'avg_pct', avg_pct)), '[]') FROM (SELECT subject_slug, subject_name, COUNT(*) AS sessions, AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct FROM base GROUP BY subject_slug ORDER BY sessions DESC)) AS bySubject, (SELECT COALESCE(json_group_array(json_object( 'id', id, 'score', score, 'total', total, 'finished_at', finished_at, 'subject_slug', subject_slug)), '[]') FROM (SELECT id, score, total, finished_at, subject_slug FROM base ORDER BY finished_at ASC LIMIT 20)) AS trend, (SELECT json_object( 'sessions', COUNT(*), 'correct', COALESCE(SUM(score), 0), 'questions',COALESCE(SUM(total), 0), 'avg_pct', COALESCE(AVG(CASE WHEN total>0 THEN score*100.0/total END), 0)) FROM base) AS totals, (SELECT MAX(started_at) FROM test_sessions WHERE user_id = @uid) AS lastSessionDate, (SELECT json_object('cnt', cnt, 'avg_pct', avg_pct) FROM week_activity) AS weekActivity `), weakTopics: db.prepare(` SELECT t.name AS topic, s.name AS subject_name, COUNT(ua.id) AS total, SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong, ROUND(CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL) / COUNT(ua.id) * 100, 0) AS error_pct FROM user_answers ua JOIN test_sessions ts ON ts.id = ua.session_id JOIN questions q ON q.id = ua.question_id JOIN topics t ON t.id = q.topic_id JOIN subjects s ON s.id = q.subject_id WHERE ts.user_id = ? AND ts.status = 'completed' AND q.topic_id IS NOT NULL GROUP BY q.topic_id HAVING total >= 2 ORDER BY error_pct DESC, wrong DESC LIMIT 5 `), courseProgress: db.prepare(` SELECT c.id, c.title, c.cover_emoji, c.subject_slug, COUNT(l.id) AS total_lessons, COUNT(lp.id) AS done_lessons FROM courses c JOIN lessons l ON l.course_id = c.id AND l.is_published = 1 LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = @uid WHERE c.is_published = 1 GROUP BY c.id HAVING done_lessons > 0 ORDER BY done_lessons * 1.0 / total_lessons DESC LIMIT 10 `), upcomingDeadlines: db.prepare(` SELECT a.id, a.title, a.deadline, a.subject_slug, (SELECT COUNT(*) FROM assignment_sessions ases JOIN test_sessions ts ON ts.id = ases.session_id WHERE ases.assignment_id = a.id AND ts.user_id = ? AND ts.status = 'completed') AS done FROM assignments a JOIN class_members cm ON cm.class_id = a.class_id AND cm.user_id = ? WHERE a.deadline IS NOT NULL AND a.deadline >= date('now', '-7 days') ORDER BY a.deadline ASC LIMIT 5 `), recentSubmissions: db.prepare(` SELECT s.id, s.original_name, s.status, s.grade, s.submitted_at, a.title AS assignment_title FROM submissions s LEFT JOIN assignments a ON a.id = s.assignment_id WHERE s.student_id = ? ORDER BY s.submitted_at DESC LIMIT 10 `), notifications: db.prepare(` SELECT id, type, message, is_read, created_at FROM parent_notifications WHERE parent_link_id = ? ORDER BY created_at DESC LIMIT 50 `), markNotifRead: db.prepare('UPDATE parent_notifications SET is_read = 1 WHERE id = ? AND parent_link_id = ?'), unreadCount: db.prepare('SELECT COUNT(*) AS cnt FROM parent_notifications WHERE parent_link_id = ? AND is_read = 0'), history: db.prepare(` SELECT ts.id, ts.mode, ts.score, ts.total, ts.status, ts.started_at, ts.finished_at, s.slug AS subject_slug, s.name AS subject_name FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id WHERE ts.user_id = ? AND ts.id < ? ORDER BY ts.id DESC LIMIT ? `), historyFirst: db.prepare(` SELECT ts.id, ts.mode, ts.score, ts.total, ts.status, ts.started_at, ts.finished_at, s.slug AS subject_slug, s.name AS subject_name FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id WHERE ts.user_id = ? ORDER BY ts.id DESC LIMIT ? `), }; /* ══════════════════════════════════════════════════════════════════════ STUDENT ENDPOINTS (regular authMiddleware) ══════════════════════════════════════════════════════════════════════ */ /* ── GET /api/parent/my-links ──────────────────────────────────────── */ function getMyLinks(req, res) { res.json(stmts.linksByStudent.all(req.user.id)); } /* ── POST /api/parent/links ────────────────────────────────────────── */ function createLink(req, res) { const { cnt } = stmts.linkCount.get(req.user.id); if (cnt >= 3) return res.status(400).json({ error: 'Maximum 3 parent links allowed' }); const label = (req.body.label || '').trim().slice(0, 50) || 'Родитель'; const token = crypto.randomBytes(24).toString('hex'); const r = stmts.insertLink.run(req.user.id, token, label); res.status(201).json({ id: r.lastInsertRowid, token, label, url: `${req.protocol}://${req.get('host')}/parent?t=${token}`, }); } /* ── PATCH /api/parent/links/:id ───────────────────────────────────── */ function updateLink(req, res) { const link = stmts.linkById.get(Number(req.params.id)); if (!link || link.student_id !== req.user.id) return res.status(404).json({ error: 'Link not found' }); const label = req.body.label !== undefined ? String(req.body.label).trim().slice(0, 50) : link.label; const is_active = req.body.is_active !== undefined ? (req.body.is_active ? 1 : 0) : link.is_active; stmts.updateLink.run(label, is_active, link.id); res.json({ ok: true }); } /* ── DELETE /api/parent/links/:id ──────────────────────────────────── */ function deleteLink(req, res) { const link = stmts.linkById.get(Number(req.params.id)); if (!link || link.student_id !== req.user.id) return res.status(404).json({ error: 'Link not found' }); stmts.deleteLink.run(link.id); res.json({ ok: true }); } /* ══════════════════════════════════════════════════════════════════════ PARENT ENDPOINTS (parentAuth middleware) ══════════════════════════════════════════════════════════════════════ */ /* ── POST /api/parent/auth — exchange link token for parent JWT ────── */ function exchangeToken(req, res) { const { token } = req.body; if (!token || typeof token !== 'string') return res.status(400).json({ error: 'token required' }); const link = stmts.linkByToken.get(token); if (!link || !link.is_active) return res.status(404).json({ error: 'Link not found or deactivated' }); if (link.expires_at && new Date(link.expires_at) < new Date()) return res.status(410).json({ error: 'Link expired' }); // Update last_used (only here, not on every request) try { stmts.updateLastUsed.run(link.id); } catch {} const parentJwt = jwt.sign( { type: 'parent', linkId: link.id, studentId: link.student_id }, JWT_SECRET, { algorithm: 'HS256', expiresIn: '24h' } ); // Inline student fetch (avoid importing studentBasic for this one-off) const student = db.prepare('SELECT name, level, streak_current FROM users WHERE id = ?').get(link.student_id); res.json({ jwt: parentJwt, student: { name: student?.name, level: student?.level, streak_current: student?.streak_current, }, }); } /* ── GET /api/parent/dashboard — aggregated overview ───────────────── Optimized: 1 mega CTE + 3 focused queries = 4 total (was 9) ──────────────────────────────────────────────────────────────────── */ function getDashboard(req, res) { const uid = req.parent.studentId; // 1. Mega query: student + stats + heatmap + weekly + totals + recent activity const row = stmts.dashboardMega.get({ uid }); const student = JSON.parse(row.student); if (!student) return res.status(404).json({ error: 'Student not found' }); const weekly = JSON.parse(row.weekly); const heatmap = JSON.parse(row.heatmap); const bySubject = JSON.parse(row.bySubject); const trend = JSON.parse(row.trend); // Already ASC from SQL const totals = JSON.parse(row.totals); const weekAct = JSON.parse(row.weekActivity); // 2. Weak topics (separate — heavy JOIN, can't merge into CTE) const weakTopics = stmts.weakTopics.all(uid); // 3. Deadlines + submissions (lightweight) const deadlines = stmts.upcomingDeadlines.all(uid, uid); const submissions = stmts.recentSubmissions.all(uid); // 4. Course progress + unread notifs const courseProgress = stmts.courseProgress.all({ uid }); const { cnt: unreadNotifs } = stmts.unreadCount.get(req.parent.linkId); // Alerts (computed in JS, zero DB cost) const alerts = []; if (row.lastSessionDate) { const daysSince = Math.floor((Date.now() - new Date(row.lastSessionDate).getTime()) / 86400000); if (daysSince >= 3) { alerts.push({ type: 'low_activity', icon: 'alert-triangle', message: `${student.name} не занимался ${daysSince} дней` }); } } for (const d of deadlines) { if (d.done === 0 && new Date(d.deadline) < new Date()) { alerts.push({ type: 'deadline_missed', icon: 'clock', message: `Пропущен дедлайн: ${d.title}` }); } } res.json({ student, totals: { sessions: totals.sessions || 0, correct: totals.correct || 0, questions: totals.questions || 0, avgPct: Math.round(totals.avg_pct || 0), }, recentActivity: { lastSessionDate: row.lastSessionDate || null, sessionsThisWeek: weekAct?.cnt || 0, avgPctThisWeek: Math.round(weekAct?.avg_pct || 0), }, weeklyStats: weekly.map(r => ({ week: r.week, sessions: r.sessions, avgPct: Math.round(r.avg_pct || 0) })), heatmap: heatmap.map(r => ({ day: r.day, count: r.count })), bySubject: bySubject.map(r => ({ slug: r.slug, name: r.name, sessions: r.sessions, avgPct: Math.round(r.avg_pct || 0), })), trend: trend.map(r => ({ pct: r.total > 0 ? Math.round(r.score * 100 / r.total) : 0, date: r.finished_at, subject: r.subject_slug, })), weakTopics: weakTopics.map(r => ({ topic: r.topic, subject: r.subject_name, errorPct: r.error_pct, })), deadlines: deadlines.map(d => ({ id: d.id, title: d.title, deadline: d.deadline, subject: d.subject_slug, done: d.done > 0, })), submissions: submissions.map(s => ({ id: s.id, name: s.original_name, status: s.status, grade: s.grade, date: s.submitted_at, assignment: s.assignment_title, })), courseProgress: courseProgress.map(r => ({ id: r.id, title: r.title, emoji: r.cover_emoji, done: r.done_lessons, total: r.total_lessons, pct: r.total_lessons > 0 ? Math.round(r.done_lessons * 100 / r.total_lessons) : 0, })), alerts, unreadNotifs, }); } /* ── GET /api/parent/history ─────────────────────────────────────────── */ function getHistory(req, res) { const uid = req.parent.studentId; const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20)); const cursor = Number(req.query.cursor) || 0; const rows = cursor ? stmts.history.all(uid, cursor, limit) : stmts.historyFirst.all(uid, limit); const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null; res.json({ rows, nextCursor }); } /* ── GET /api/parent/notifications ───────────────────────────────────── */ function getNotifications(req, res) { res.json(stmts.notifications.all(req.parent.linkId)); } /* ── PATCH /api/parent/notifications/:id/read ────────────────────────── */ function markRead(req, res) { stmts.markNotifRead.run(Number(req.params.id), req.parent.linkId); res.json({ ok: true }); } module.exports = { getMyLinks, createLink, updateLink, deleteLink, exchangeToken, getDashboard, getHistory, getNotifications, markRead, };