feat: textbooks Phase 4 — A1+A2+A3+B4+C7 + назначение ученику
A1 — карточка ДЗ-чтения у ученика на /dashboard: - Новая ветка в buildAssignCard для assignments с textbook_id - Прогресс-бар «X из Y §», цвет берётся из textbook.color - Кнопка «Открыть / Продолжить» с deep-link на первый требуемый параграф - В classify(): textbook_all_read → done, deadline → overdue A2 — авто-проверка выполнения: - При POST /:slug/progress с mark_read: проверяются активные textbook-assignments - Если все требуемые § прочитаны → INSERT в assignment_completion - SSE-уведомление учителю «Ученик завершил чтение: <title>» - myAssignments возвращает completed_at и textbook_all_read A3 — учительский UI прогресса класса: - Новая страница /textbook-progress (учитель/админ) - Селекторы «учебник × класс» → таблица учеников с прогрессом - Сортировка по количеству прочитанного, дата last_at - Кнопка «Прогресс класса» добавлена в /textbooks (видна учителям) B4 — admin-UI управления учебниками: - /admin-textbooks (только admin) — таблица всех учебников - Inline-редактирование title/author, тоггл is_active - Колонка «Читателей» (count из textbook_progress) - Endpoints: GET /api/textbooks/admin/all, PATCH /admin/:id C7 — закладки/заметки внутри учебника: - Таблица textbook_bookmarks (user, textbook, para, text, note, color) - API: GET/POST/PATCH/DELETE для CRUD закладок - В tracker: при выделении текста (8-400 симв) появляется плавающая «+ Закладка» - Кнопка-иконка в overlay top-left открывает панель «Мои закладки» - Хранится paragraph-якорь, цвет, заметка, кнопка удалить Назначение ученику (в дополнение к классу): - В модалке /textbooks — переключатель «Классу / Ученику» - Поиск ученика по имени/email через /api/classes/students - Submit использует POST /api/assignments (createDirectAssignment) - createDirectAssignment расширен textbook_slug + textbook_paragraphs - Учитель может назначать только ученикам своих классов myAssignments расширен: возвращает textbook fields + post-process считает textbook_required_count, textbook_read_count, textbook_all_read. Deep-link поддержка: /textbook/<slug>#pN в tracker.js — на load и hashchange вызывает setParaTab(pN) (нативная функция учебника). Миграция 005: assignment_completion + textbook_bookmarks + индексы.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
+175
-26
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user