'use strict'; /* /api/access — управление доступом к учебникам и экзамен-модулям. * * Доступно админам (все классы/ученики) и учителям (только свои классы и * ученики своих классов / привязанные ученики). Модель — allowlist, * правило ученика важнее правила класса (см. services/contentAccess.js). */ const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware, requireRole } = require('../middleware/auth'); const { audit } = require('../utils/audit'); router.use(authMiddleware); router.use(requireRole('admin', 'teacher')); const isAdmin = (req) => req.user.role === 'admin'; /* ── Каталог контента, которым можно управлять ─────────────────────────── */ /* GET /api/access/catalog → { textbooks:[...], exams:[...] } */ router.get('/catalog', (_req, res) => { const textbooks = db.prepare(` SELECT slug, title, subject, grade, color FROM textbooks WHERE is_active = 1 AND parent_slug IS NULL ORDER BY sort_order, subject, grade `).all(); const exams = db.prepare(` SELECT exam_key, title, subject_slug, grade FROM exam_tracks WHERE enabled = 1 ORDER BY sort_order, exam_key `).all(); let sims = []; try { sims = db.prepare(` SELECT id, title, cat AS subject, grade FROM lab_sims WHERE enabled = 1 ORDER BY sort_order, id `).all(); } catch (_e) { /* lab_sims может отсутствовать на старом инстансе — деградация */ } let courses = []; try { courses = db.prepare(` SELECT CAST(id AS TEXT) AS id, title, subject_slug AS subject FROM courses WHERE is_published = 1 ORDER BY subject_slug, order_index, id `).all(); } catch (_e) { /* деградация */ } res.json({ textbooks, exams, sims, courses }); }); /* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */ /* GET /api/access/targets → { classes:[{id,name,students:[...]}], looseStudents:[...] } */ router.get('/targets', (req, res) => { const admin = isAdmin(req); const classes = admin ? db.prepare(`SELECT c.id, c.name, u.name AS teacher_name FROM classes c LEFT JOIN users u ON u.id = c.teacher_id ORDER BY c.name`).all() : db.prepare(`SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name`) .all(req.user.id); const membersStmt = db.prepare(` SELECT u.id, u.name, u.email FROM class_members cm JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name `); const classList = classes.map(c => ({ ...c, students: membersStmt.all(c.id) })); /* «Отдельные ученики» — без класса (admin) / привязанные не в моих классах (teacher). */ let looseStudents; if (admin) { looseStudents = db.prepare(` SELECT id, name, email FROM users WHERE role IN ('student','free_student') AND id NOT IN (SELECT user_id FROM class_members) ORDER BY name LIMIT 500 `).all(); } else { looseStudents = db.prepare(` SELECT u.id, u.name, u.email FROM teacher_students ts JOIN users u ON u.id = ts.student_id WHERE ts.teacher_id = ? AND u.id NOT IN ( SELECT cm.user_id FROM class_members cm JOIN classes c ON c.id = cm.class_id WHERE c.teacher_id = ?) ORDER BY u.name `).all(req.user.id, req.user.id); } res.json({ classes: classList, looseStudents }); }); /* ── Сводка: сколько классов открыто по каждому контенту ───────────────── */ /* GET /api/access/summary → { totalClasses, textbooks:{[slug]:openCount}, exams:{[key]:openCount} } */ router.get('/summary', (req, res) => { const admin = isAdmin(req); const totalClasses = admin ? db.prepare('SELECT COUNT(*) n FROM classes').get().n : db.prepare('SELECT COUNT(*) n FROM classes WHERE teacher_id = ?').get(req.user.id).n; const rows = admin ? db.prepare(` SELECT content_type, content_ref, COUNT(DISTINCT target_id) n FROM content_access WHERE scope = 'class' AND allow = 1 GROUP BY content_type, content_ref`).all() : db.prepare(` SELECT content_type, content_ref, COUNT(DISTINCT target_id) n FROM content_access WHERE scope = 'class' AND allow = 1 AND target_id IN (SELECT id FROM classes WHERE teacher_id = ?) GROUP BY content_type, content_ref`).all(req.user.id); const out = { textbook: {}, exam: {}, sim: {}, course: {} }; for (const r of rows) { (out[r.content_type] || (out[r.content_type] = {}))[r.content_ref] = r.n; } res.json({ totalClasses, textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course }); }); /* ── Матрица класс × контент (обзор и правка одним экраном) ────────────── */ /* GET /api/access/matrix → { classes:[{id,name}], open:{ [class_id]:{ textbook:[ref], exam:[ref] } } } */ router.get('/matrix', (req, res) => { const admin = isAdmin(req); const classes = admin ? db.prepare('SELECT id, name FROM classes ORDER BY name').all() : db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(req.user.id); const open = {}; classes.forEach(c => { open[c.id] = { textbook: [], exam: [], sim: [], course: [] }; }); const ids = classes.map(c => c.id); if (ids.length) { const ph = ids.map(() => '?').join(','); const rows = db.prepare(` SELECT content_type, content_ref, target_id FROM content_access WHERE scope = 'class' AND allow = 1 AND target_id IN (${ph})`).all(...ids); for (const r of rows) { const o = open[r.target_id]; if (o && o[r.content_type]) o[r.content_type].push(r.content_ref); } } res.json({ classes, open }); }); /* ── История изменений правил доступа к контенту (только админ) ────────── */ /* GET /api/access/log?content_type=&content_ref= → [{ action:'grant'|'deny'|'inherit', actor, targetName, at }] (последние 50) */ router.get('/log', requireRole('admin'), (req, res) => { const { content_type, content_ref } = req.query; if (!['textbook', 'exam', 'sim', 'course'].includes(content_type) || !content_ref) { return res.status(400).json({ error: 'content_type и content_ref обязательны' }); } const rows = db.prepare(` SELECT a.action, a.detail, a.created_at, u.name AS actor FROM admin_audit_log a LEFT JOIN users u ON u.id = a.admin_id WHERE a.action LIKE 'access.%' AND a.target = ? ORDER BY a.id DESC LIMIT 50 `).all(content_type + ':' + content_ref); const out = rows.map(r => { const [scope, tid] = String(r.detail || '').split(':'); let targetName = r.detail || ''; if (scope === 'class') { const c = db.prepare('SELECT name FROM classes WHERE id = ?').get(tid); targetName = c ? `класс «${c.name}»` : `класс #${tid}`; } else if (scope === 'student') { const s = db.prepare('SELECT name, email FROM users WHERE id = ?').get(tid); targetName = s ? `ученик ${s.name || s.email}` : `ученик #${tid}`; } return { action: r.action.replace('access.', ''), actor: r.actor || '—', targetName, at: r.created_at }; }); res.json(out); }); /* ── Текущие правила для одного контента ───────────────────────────────── */ /* GET /api/access/rules?content_type=&content_ref= → { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */ router.get('/rules', (req, res) => { const { content_type, content_ref } = req.query; if (!['textbook', 'exam'].includes(content_type) || !content_ref) { return res.status(400).json({ error: 'content_type и content_ref обязательны' }); } const rows = db.prepare(` SELECT scope, target_id, allow FROM content_access WHERE content_type = ? AND content_ref = ? `).all(content_type, content_ref); const classRules = {}, studentRules = {}; for (const r of rows) { if (r.scope === 'class') classRules[r.target_id] = r.allow; else studentRules[r.target_id] = r.allow; } res.json({ classRules, studentRules }); }); /* ── Проверка прав учителя на конкретную цель ──────────────────────────── */ function teacherOwnsClass(teacherId, classId) { return !!db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, teacherId); } /* ── Открытый классу контент (для вида «по классу») ────────────────────── */ /* GET /api/access/class/:id → { textbooks:[slug], exams:[exam_key] } (allow=1) */ router.get('/class/:id', requireRole('admin', 'teacher'), (req, res) => { const cid = Number(req.params.id); if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id' }); if (!isAdmin(req) && !teacherOwnsClass(req.user.id, cid)) { return res.status(403).json({ error: 'Нет прав на этот класс' }); } const rows = db.prepare(` SELECT content_type, content_ref FROM content_access WHERE scope = 'class' AND target_id = ? AND allow = 1 `).all(cid); const out = { textbook: [], exam: [], sim: [], course: [] }; for (const r of rows) (out[r.content_type] || (out[r.content_type] = [])).push(r.content_ref); res.json({ textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course }); }); function teacherCanManageStudent(teacherId, studentId) { const inClass = db.prepare(` SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id WHERE c.teacher_id = ? AND cm.user_id = ? LIMIT 1`).get(teacherId, studentId); if (inClass) return true; return !!db.prepare('SELECT 1 FROM teacher_students WHERE teacher_id = ? AND student_id = ?') .get(teacherId, studentId); } /* ── Установка / снятие правила ────────────────────────────────────────── */ /* POST /api/access/rules body: { content_type, content_ref, scope, target_id, allow } allow: 1 (открыть) | 0 (закрыть-исключение) | null/'inherit' (удалить правило) */ router.post('/rules', (req, res) => { const { content_type, content_ref, scope, target_id } = req.body || {}; let { allow } = req.body || {}; if (!['textbook', 'exam'].includes(content_type)) { return res.status(400).json({ error: 'неверный content_type' }); } if (!['class', 'student'].includes(scope)) { return res.status(400).json({ error: 'неверный scope' }); } const tid = Number(target_id); if (!Number.isInteger(tid) || tid <= 0) { return res.status(400).json({ error: 'неверный target_id' }); } /* Валидация существования контента. */ if (content_type === 'textbook') { const ok = db.prepare('SELECT 1 FROM textbooks WHERE slug = ? AND parent_slug IS NULL').get(content_ref); if (!ok) return res.status(404).json({ error: 'учебник не найден' }); } else { const ok = db.prepare('SELECT 1 FROM exam_tracks WHERE exam_key = ?').get(content_ref); if (!ok) return res.status(404).json({ error: 'экзамен-трек не найден' }); } /* Скоупинг учителя. */ if (!isAdmin(req)) { const allowed = scope === 'class' ? teacherOwnsClass(req.user.id, tid) : teacherCanManageStudent(req.user.id, tid); if (!allowed) return res.status(403).json({ error: 'Нет прав на эту цель' }); } /* allow === null / 'inherit' / undefined → удалить правило (наследование). */ if (allow === null || allow === undefined || allow === 'inherit') { db.prepare(`DELETE FROM content_access WHERE content_type = ? AND content_ref = ? AND scope = ? AND target_id = ?`) .run(content_type, content_ref, scope, tid); audit(req, 'access.inherit', `${content_type}:${content_ref}`, `${scope}:${tid}`); return res.json({ ok: true, allow: null }); } allow = (allow === 1 || allow === true || allow === '1') ? 1 : 0; db.prepare(` INSERT INTO content_access (content_type, content_ref, scope, target_id, allow, created_by) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (content_type, content_ref, scope, target_id) DO UPDATE SET allow = excluded.allow, created_by = excluded.created_by, created_at = datetime('now') `).run(content_type, content_ref, scope, tid, allow, req.user.id); audit(req, allow ? 'access.grant' : 'access.deny', `${content_type}:${content_ref}`, `${scope}:${tid}`); res.json({ ok: true, allow }); }); module.exports = router;