feat(access): доступ к учебникам и экзаменам по классам/ученикам из админ-панели

Модель allowlist (закрыто по умолчанию), правило ученика важнее класса.
Управляют админ (все) и учителя (свои классы/ученики).

- миграция 040: таблица content_access + непрерывный переход
  (всем существующим классам открыт текущий контент)
- сервис contentAccess: резолвинг доступа, главы наследуют хаб
- API /api/access (catalog/targets/rules) для admin+teacher
- гейты: каталог учебников, router.param slug/examKey, фильтр tracks
- клиентские редиректы на /403 (textbook-tracker, exam-prep boot)
- раздел админки «Доступ к учебникам»: классы + ученики (tri-state)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 12:33:05 +03:00
parent 98f955a85e
commit 471171b77c
12 changed files with 564 additions and 4 deletions
+95
View File
@@ -0,0 +1,95 @@
'use strict';
/* contentAccess — резолвинг доступа к учебникам / экзамен-модулям.
*
* Модель: ALLOWLIST. По умолчанию закрыто. Правило ученика важнее правила
* класса. См. миграцию 040_content_access.sql.
*
* canAccessTextbook(user, slug) → bool
* canAccessExam(user, examKey) → bool
* filterTextbooks(user, rows) → отфильтрованный список (rows[*].slug)
* allowedRefs(userId, type) → Set<content_ref> доступных пользователю
*/
const db = require('../db/db');
const PRIVILEGED = new Set(['admin', 'teacher']);
const _parentOf = db.prepare('SELECT parent_slug FROM textbooks WHERE slug = ?');
/* Глава наследует доступ родителя-хаба; верхнеуровневый учебник — ключ = own slug. */
function textbookAccessKey(slug) {
const row = _parentOf.get(slug);
return row && row.parent_slug ? row.parent_slug : slug;
}
const _studentRule = db.prepare(`
SELECT allow FROM content_access
WHERE content_type = ? AND content_ref = ? AND scope = 'student' AND target_id = ?
`);
const _classRule = db.prepare(`
SELECT MAX(allow) AS any_allow, COUNT(*) AS n
FROM content_access
WHERE content_type = ? AND content_ref = ? AND scope = 'class'
AND target_id IN (SELECT class_id FROM class_members WHERE user_id = ?)
`);
function resolve(userId, type, ref) {
const s = _studentRule.get(type, ref, userId);
if (s) return s.allow === 1; // правило ученика побеждает
const c = _classRule.get(type, ref, userId);
if (c && c.n > 0) return c.any_allow === 1; // открыт хотя бы одним классом
return false; // allowlist — по умолчанию закрыто
}
function canAccess(user, type, ref) {
if (!user) return false;
if (PRIVILEGED.has(user.role)) return true; // админ/учитель видят весь контент
return resolve(user.id, type, ref);
}
function canAccessTextbook(user, slug) {
return canAccess(user, 'textbook', textbookAccessKey(slug));
}
function canAccessExam(user, examKey) {
return canAccess(user, 'exam', examKey);
}
/* Множество доступных пользователю content_ref для типа (bulk, для каталога). */
const _allStudentRules = db.prepare(`
SELECT content_ref, allow FROM content_access
WHERE content_type = ? AND scope = 'student' AND target_id = ?
`);
const _allClassRules = db.prepare(`
SELECT content_ref, MAX(allow) AS any_allow
FROM content_access
WHERE content_type = ? AND scope = 'class'
AND target_id IN (SELECT class_id FROM class_members WHERE user_id = ?)
GROUP BY content_ref
`);
function allowedRefs(userId, type) {
const out = new Set();
for (const r of _allClassRules.all(type, userId)) {
if (r.any_allow === 1) out.add(r.content_ref);
}
for (const r of _allStudentRules.all(type, userId)) {
if (r.allow === 1) out.add(r.content_ref); // ученик-разрешение добавляет
else out.delete(r.content_ref); // ученик-запрет снимает
}
return out;
}
/* Фильтрует список учебников верхнего уровня (каждый row имеет .slug). */
function filterTextbooks(user, rows) {
if (!user) return [];
if (PRIVILEGED.has(user.role)) return rows;
const allow = allowedRefs(user.id, 'textbook');
return rows.filter(r => allow.has(r.slug));
}
module.exports = {
PRIVILEGED,
textbookAccessKey,
canAccess,
canAccessTextbook,
canAccessExam,
allowedRefs,
filterTextbooks,
};