Files
Learn_System/backend/src/controllers/parentController.js
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
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>
2026-04-12 10:10:37 +03:00

349 lines
16 KiB
JavaScript

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