be4d43105e
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>
349 lines
16 KiB
JavaScript
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,
|
|
};
|