diff --git a/backend/src/controllers/assignmentController.js b/backend/src/controllers/assignmentController.js index f0197bc..42698e5 100644 --- a/backend/src/controllers/assignmentController.js +++ b/backend/src/controllers/assignmentController.js @@ -195,11 +195,15 @@ function myAssignments(req, res) { SELECT * FROM ( SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at, a.file_id, f.title AS file_title, + a.textbook_id, a.textbook_paragraphs, + tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count, + tp.paragraphs_read AS textbook_read, c.name AS class_name, c.id AS class_id, u.name AS teacher_name, latest.session_id, ts.score, ts.total, ts.status AS session_status, ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done, + ac.completed_at AS completed_at, a.is_homework, a.max_attempts, (SELECT COUNT(*) FROM assignment_sessions ax JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed' @@ -209,6 +213,9 @@ function myAssignments(req, res) { JOIN users u ON u.id = c.teacher_id JOIN assignments a ON a.class_id = c.id AND a.user_id IS NULL LEFT JOIN files f ON f.id = a.file_id + LEFT JOIN textbooks tb ON tb.id = a.textbook_id + LEFT JOIN textbook_progress tp ON tp.user_id = cm.user_id AND tp.textbook_id = a.textbook_id + LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = cm.user_id LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = cm.user_id AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = cm.user_id) LEFT JOIN test_sessions ts ON ts.id = latest.session_id @@ -216,11 +223,15 @@ function myAssignments(req, res) { UNION ALL SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at, a.file_id, f.title AS file_title, + a.textbook_id, a.textbook_paragraphs, + tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count, + tp.paragraphs_read AS textbook_read, 'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name, latest.session_id, ts.score, ts.total, ts.status AS session_status, ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done, + ac.completed_at AS completed_at, a.is_homework, a.max_attempts, (SELECT COUNT(*) FROM assignment_sessions ax JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed' @@ -228,15 +239,58 @@ function myAssignments(req, res) { FROM assignments a JOIN users u ON u.id = a.created_by LEFT JOIN files f ON f.id = a.file_id + LEFT JOIN textbooks tb ON tb.id = a.textbook_id + LEFT JOIN textbook_progress tp ON tp.user_id = ? AND tp.textbook_id = a.textbook_id + LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = ? LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = ? AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = ?) LEFT JOIN test_sessions ts ON ts.id = latest.session_id WHERE a.user_id = ? ) ORDER BY done ASC, deadline ASC, created_at DESC - `).all(uid, uid, uid, uid, uid); + `).all(uid, uid, uid, uid, uid, uid, uid); + + // Post-process: compute textbook reading completion from required vs read paragraphs + for (const r of rows) { + if (r.textbook_id) { + const required = parseTextbookParas(r.textbook_paragraphs, r.textbook_para_count); + let read = []; + try { read = JSON.parse(r.textbook_read || '[]'); } catch {} + const readKeys = new Set(read); + const requiredKeys = required.map(n => 'p' + n); + const readCount = requiredKeys.filter(k => readKeys.has(k)).length; + r.textbook_required_count = requiredKeys.length; + r.textbook_read_count = readCount; + r.textbook_all_read = requiredKeys.length > 0 && readCount === requiredKeys.length; + if (r.textbook_all_read || r.completed_at) r.done = 1; + } + // Strip raw paragraphs_read JSON from response (not needed by client) + delete r.textbook_read; + } + res.json(rows); } +/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc. + If empty/null, returns [1..fallback] (the whole book). */ +function parseTextbookParas(spec, fallback) { + if (!spec || !spec.trim()) { + return Array.from({ length: fallback || 0 }, (_, i) => i + 1); + } + const out = new Set(); + for (const chunk of spec.split(',')) { + const part = chunk.trim(); + if (!part) continue; + const dash = part.match(/^(\d+)\s*[-–]\s*(\d+)$/); + if (dash) { + const a = Number(dash[1]), b = Number(dash[2]); + for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i); + } else if (/^\d+$/.test(part)) { + out.add(Number(part)); + } + } + return [...out].sort((a, b) => a - b); +} + /* ── POST /api/assignments/:id/start ── student starts session ─────────── */ function startAssignment(req, res) { const uid = req.user.id; @@ -452,7 +506,8 @@ function assignmentQuestionStats(req, res) { /* ── POST /api/assignments ── direct assignment to a single student ──────── */ function createDirectAssignment(req, res) { - const { deadline, student_email, student_id, file_id, is_homework = 1 } = req.body; + const { deadline, student_email, student_id, file_id, is_homework = 1, + textbook_slug, textbook_paragraphs } = req.body; const mode = req.body.mode || 'exam'; const count = Number(req.body.count) || 25; let { title, subject_slug, test_id } = req.body; @@ -465,11 +520,11 @@ function createDirectAssignment(req, res) { let student; if (student_id) { - student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'").get(Number(student_id)); + student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role IN ('student','free_student')").get(Number(student_id)); if (!student) return res.status(404).json({ error: 'Ученик не найден' }); } else { if (!student_email?.trim()) return res.status(400).json({ error: 'student_email required' }); - student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'") + student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role IN ('student','free_student')") .get(student_email.trim().toLowerCase()); if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' }); } @@ -490,6 +545,15 @@ function createDirectAssignment(req, res) { if (!t) return res.status(400).json({ error: 'Test not found' }); subject_slug = t.subject_slug; } + // Textbook: resolve slug → id, derive subject + let textbook_id = null; + if (textbook_slug) { + const tb = db.prepare('SELECT id, subject FROM textbooks WHERE slug=? AND is_active=1').get(textbook_slug); + if (!tb) return res.status(400).json({ error: 'Учебник не найден' }); + textbook_id = tb.id; + if (!subject_slug) subject_slug = tb.subject; + } + if (file_id && !subject_slug) { const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id); if (f?.subject_slug) subject_slug = f.subject_slug; @@ -498,9 +562,9 @@ function createDirectAssignment(req, res) { if (!subject_slug) subject_slug = 'other'; const r = db.prepare(` - INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0); + INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework, textbook_id, textbook_paragraphs) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0, textbook_id, textbook_paragraphs || null); // Уведомление ученику pushNotif(student.id, 'assignment', `Для вас задание: «${title.trim()}»`, '/dashboard'); diff --git a/backend/src/db/migrations/005_textbook_extras.sql b/backend/src/db/migrations/005_textbook_extras.sql new file mode 100644 index 0000000..e5dd86f --- /dev/null +++ b/backend/src/db/migrations/005_textbook_extras.sql @@ -0,0 +1,22 @@ +-- Per-user-per-assignment completion (textbook reading, future file-read, etc.) +-- Used to mark non-test assignments as done. +CREATE TABLE assignment_completion ( + assignment_id INTEGER NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + completed_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (assignment_id, user_id) +); + +-- Bookmarks/highlights inside textbooks (one row per highlight) +CREATE TABLE textbook_bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + textbook_id INTEGER NOT NULL REFERENCES textbooks(id) ON DELETE CASCADE, + para TEXT, -- 'p15' (paragraph anchor, optional) + text TEXT NOT NULL, -- highlighted text snippet (max 400 chars) + note TEXT NOT NULL DEFAULT '', -- user's comment + color TEXT NOT NULL DEFAULT 'yellow', -- 'yellow','green','blue','pink' + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_textbook_bookmarks_user ON textbook_bookmarks (user_id, textbook_id); +CREATE INDEX idx_textbook_bookmarks_textbook ON textbook_bookmarks (textbook_id); diff --git a/backend/src/routes/textbooks.js b/backend/src/routes/textbooks.js index 371578b..c98ffe5 100644 --- a/backend/src/routes/textbooks.js +++ b/backend/src/routes/textbooks.js @@ -2,62 +2,146 @@ const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware, requireRole } = require('../middleware/auth'); +const { emit } = require('../sse'); router.use(authMiddleware); +/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,...]; empty → [1..fallback] */ +function parseTextbookParas(spec, fallback) { + if (!spec || !spec.trim()) return Array.from({ length: fallback || 0 }, (_, i) => i + 1); + const out = new Set(); + for (const chunk of spec.split(',')) { + const part = chunk.trim(); + if (!part) continue; + const dash = part.match(/^(\d+)\s*[-–]\s*(\d+)$/); + if (dash) { + const a = Number(dash[1]), b = Number(dash[2]); + for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i); + } else if (/^\d+$/.test(part)) out.add(Number(part)); + } + return [...out].sort((a, b) => a - b); +} + +/* After a paragraph is marked read, scan user's textbook assignments and mark + completed if all required paragraphs are now in their read set. */ +function checkAssignmentCompletion(userId, textbookId, readSet) { + const candidates = db.prepare(` + SELECT a.id, a.title, a.created_by, a.textbook_paragraphs, t.para_count + FROM assignments a + JOIN textbooks t ON t.id = a.textbook_id + LEFT JOIN assignment_completion ac ON ac.assignment_id = a.id AND ac.user_id = ? + WHERE a.textbook_id = ? + AND ac.assignment_id IS NULL + AND ( + a.user_id = ? + OR (a.user_id IS NULL AND a.class_id IS NOT NULL + AND EXISTS (SELECT 1 FROM class_members cm WHERE cm.class_id = a.class_id AND cm.user_id = ?)) + ) + `).all(userId, textbookId, userId, userId); + + for (const a of candidates) { + const required = parseTextbookParas(a.textbook_paragraphs, a.para_count); + if (!required.length) continue; + const allRead = required.every(n => readSet.has('p' + n)); + if (allRead) { + db.prepare('INSERT OR IGNORE INTO assignment_completion (assignment_id, user_id) VALUES (?, ?)').run(a.id, userId); + try { + emit(a.created_by, { + type: 'notification', notif_type: 'assignment_done', + message: `Ученик завершил чтение: «${a.title}»`, link: '/classes', + }); + } catch {} + } + } +} + +/* ════════════════════════════════════════════════ + LITERAL ROUTES FIRST — must come before /:slug + ════════════════════════════════════════════════ */ + /* GET /api/textbooks — list with current user's progress */ router.get('/', (req, res) => { const rows = db.prepare(` SELECT t.id, t.slug, t.subject, t.grade, t.title, t.author, t.description, t.html_path, t.para_count, t.color, t.sort_order - FROM textbooks t - WHERE t.is_active = 1 + FROM textbooks t WHERE t.is_active = 1 ORDER BY t.sort_order, t.subject, t.grade `).all(); - const myProgress = db.prepare(` SELECT textbook_id, paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=? `).all(req.user.id); - const progressMap = {}; + const map = {}; for (const p of myProgress) { let arr = []; try { arr = JSON.parse(p.paragraphs_read || '[]'); } catch {} - progressMap[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at }; + map[p.textbook_id] = { read: arr, last_para: p.last_para, last_at: p.last_at }; } - - res.json({ - textbooks: rows.map(t => ({ - ...t, - progress: progressMap[t.id] || { read: [], last_para: null, last_at: null }, - })), - }); + res.json({ textbooks: rows.map(t => ({ ...t, progress: map[t.id] || { read: [], last_para: null, last_at: null } })) }); }); +/* GET /api/textbooks/bookmarks/all — all my bookmarks across textbooks */ +router.get('/bookmarks/all', (req, res) => { + const rows = db.prepare(` + SELECT b.id, b.para, b.text, b.note, b.color, b.created_at, + t.slug AS textbook_slug, t.title AS textbook_title, t.color AS textbook_color + FROM textbook_bookmarks b + JOIN textbooks t ON t.id = b.textbook_id + WHERE b.user_id=? ORDER BY b.id DESC + `).all(req.user.id); + res.json({ bookmarks: rows }); +}); + +/* PATCH /api/textbooks/bookmarks/:id — update note/color */ +router.patch('/bookmarks/:id', (req, res) => { + const id = Number(req.params.id); + const b = db.prepare('SELECT id, user_id FROM textbook_bookmarks WHERE id=?').get(id); + if (!b) return res.status(404).json({ error: 'Закладка не найдена' }); + if (b.user_id !== req.user.id) return res.status(403).json({ error: 'Нет доступа' }); + + const updates = [], params = []; + if (typeof req.body?.note === 'string') { updates.push('note=?'); params.push(req.body.note.slice(0, 1000)); } + if (req.body?.color && ['yellow','green','blue','pink'].includes(req.body.color)) { + updates.push('color=?'); params.push(req.body.color); + } + if (!updates.length) return res.json({ ok: true }); + params.push(id); + db.prepare(`UPDATE textbook_bookmarks SET ${updates.join(', ')} WHERE id=?`).run(...params); + res.json({ ok: true }); +}); + +/* DELETE /api/textbooks/bookmarks/:id */ +router.delete('/bookmarks/:id', (req, res) => { + const id = Number(req.params.id); + const b = db.prepare('SELECT id, user_id FROM textbook_bookmarks WHERE id=?').get(id); + if (!b) return res.status(404).json({ error: 'Закладка не найдена' }); + if (b.user_id !== req.user.id) return res.status(403).json({ error: 'Нет доступа' }); + db.prepare('DELETE FROM textbook_bookmarks WHERE id=?').run(id); + res.json({ ok: true }); +}); + +/* ════════════════════════════════════════════════ + :slug ROUTES (catch-all per textbook) + ════════════════════════════════════════════════ */ + /* GET /api/textbooks/:slug — single textbook detail */ router.get('/:slug', (req, res) => { const t = db.prepare('SELECT * FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug); if (!t) return res.status(404).json({ error: 'Учебник не найден' }); - const p = db.prepare('SELECT paragraphs_read, last_para, last_at FROM textbook_progress WHERE user_id=? AND textbook_id=?').get(req.user.id, t.id); let read = []; if (p) { try { read = JSON.parse(p.paragraphs_read || '[]'); } catch {} } - res.json({ ...t, progress: { read, last_para: p?.last_para || null, last_at: p?.last_at || null } }); }); -/* POST /api/textbooks/:slug/progress — update progress - body: { last_para?: 'p15', mark_read?: 'p15', mark_unread?: 'p15' } */ +/* POST /api/textbooks/:slug/progress — update progress */ router.post('/:slug/progress', (req, res) => { const t = db.prepare('SELECT id FROM textbooks WHERE slug=? AND is_active=1').get(req.params.slug); if (!t) return res.status(404).json({ error: 'Учебник не найден' }); const { last_para, mark_read, mark_unread } = req.body || {}; - - // Atomic upsert const existing = db.prepare('SELECT paragraphs_read FROM textbook_progress WHERE user_id=? AND textbook_id=?').get(req.user.id, t.id); let arr = []; if (existing) { try { arr = JSON.parse(existing.paragraphs_read || '[]'); } catch {} } - if (mark_read && typeof mark_read === 'string' && !arr.includes(mark_read)) arr.push(mark_read); if (mark_unread && typeof mark_unread === 'string') arr = arr.filter(p => p !== mark_unread); @@ -70,22 +154,48 @@ router.post('/:slug/progress', (req, res) => { last_at = excluded.last_at `).run(req.user.id, t.id, JSON.stringify(arr), last_para || null); + if (mark_read) { + try { checkAssignmentCompletion(req.user.id, t.id, new Set(arr)); } catch {} + } res.json({ ok: true, read: arr }); }); -/* GET /api/textbooks/:slug/class-progress — teacher view: progress of all students in class - query: ?class_id=N */ -router.get('/:slug/class-progress', requireRole('teacher', 'admin'), (req, res) => { +/* GET /api/textbooks/:slug/bookmarks — list my bookmarks for this textbook */ +router.get('/:slug/bookmarks', (req, res) => { const t = db.prepare('SELECT id FROM textbooks WHERE slug=?').get(req.params.slug); if (!t) return res.status(404).json({ error: 'Учебник не найден' }); + const rows = db.prepare(` + SELECT id, para, text, note, color, created_at FROM textbook_bookmarks + WHERE user_id=? AND textbook_id=? ORDER BY id DESC + `).all(req.user.id, t.id); + res.json({ bookmarks: rows }); +}); + +/* POST /api/textbooks/:slug/bookmarks — create */ +router.post('/:slug/bookmarks', (req, res) => { + const t = db.prepare('SELECT id FROM textbooks WHERE slug=?').get(req.params.slug); + if (!t) return res.status(404).json({ error: 'Учебник не найден' }); + const { para, text, note = '', color = 'yellow' } = req.body || {}; + if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' }); + const VALID = new Set(['yellow', 'green', 'blue', 'pink']); + const safeColor = VALID.has(color) ? color : 'yellow'; + const r = db.prepare(` + INSERT INTO textbook_bookmarks (user_id, textbook_id, para, text, note, color) + VALUES (?, ?, ?, ?, ?, ?) + `).run(req.user.id, t.id, para || null, text.slice(0, 400), String(note).slice(0, 1000), safeColor); + res.status(201).json({ id: r.lastInsertRowid }); +}); + +/* GET /api/textbooks/:slug/class-progress — teacher view (per-student progress) */ +router.get('/:slug/class-progress', requireRole('teacher', 'admin'), (req, res) => { + const t = db.prepare('SELECT id, para_count FROM textbooks WHERE slug=?').get(req.params.slug); + if (!t) return res.status(404).json({ error: 'Учебник не найден' }); const classId = Number(req.query.class_id); if (!classId) return res.status(400).json({ error: 'class_id обязателен' }); - if (req.user.role === 'teacher') { const own = db.prepare('SELECT 1 FROM classes WHERE id=? AND teacher_id=?').get(classId, req.user.id); if (!own) return res.status(403).json({ error: 'Нет доступа к классу' }); } - const rows = db.prepare(` SELECT u.id AS user_id, u.name, COALESCE(tp.paragraphs_read, '[]') AS paragraphs_read, @@ -96,14 +206,53 @@ router.get('/:slug/class-progress', requireRole('teacher', 'admin'), (req, res) WHERE cm.class_id = ? ORDER BY u.name `).all(t.id, classId); - res.json({ + total_paragraphs: t.para_count, students: rows.map(r => { let read = []; try { read = JSON.parse(r.paragraphs_read); } catch {} - return { user_id: r.user_id, name: r.name, read_count: read.length, last_para: r.last_para, last_at: r.last_at }; + return { + user_id: r.user_id, name: r.name, + read_count: read.length, paragraphs_read: read, + last_para: r.last_para, last_at: r.last_at, + }; }), }); }); +/* ════════════════════════════════════════════════ + ADMIN endpoints — full catalog management + ════════════════════════════════════════════════ */ + +/* GET /api/textbooks/admin/all — list ALL textbooks including inactive */ +router.get('/admin/all', requireRole('admin'), (_req, res) => { + const rows = db.prepare(` + SELECT t.id, t.slug, t.subject, t.grade, t.title, t.author, t.description, + t.html_path, t.para_count, t.color, t.sort_order, t.is_active, t.created_at, + (SELECT COUNT(*) FROM textbook_progress WHERE textbook_id = t.id) AS readers + FROM textbooks t ORDER BY t.sort_order, t.subject, t.grade + `).all(); + res.json({ textbooks: rows }); +}); + +/* PATCH /api/textbooks/admin/:id — edit textbook (admin only) */ +router.patch('/admin/:id', requireRole('admin'), (req, res) => { + const id = Number(req.params.id); + const t = db.prepare('SELECT id FROM textbooks WHERE id=?').get(id); + if (!t) return res.status(404).json({ error: 'Учебник не найден' }); + + const allowed = ['title', 'author', 'description', 'subject', 'grade', 'color', 'sort_order', 'is_active', 'para_count']; + const updates = [], params = []; + for (const f of allowed) { + if (req.body[f] !== undefined) { + updates.push(`${f} = ?`); + params.push(req.body[f]); + } + } + if (!updates.length) return res.json({ ok: true }); + params.push(id); + db.prepare(`UPDATE textbooks SET ${updates.join(', ')} WHERE id=?`).run(...params); + res.json({ ok: true }); +}); + module.exports = router; diff --git a/frontend/admin-textbooks.html b/frontend/admin-textbooks.html new file mode 100644 index 0000000..cc4a16a --- /dev/null +++ b/frontend/admin-textbooks.html @@ -0,0 +1,205 @@ + + +
+ + +