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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user