feat(permissions): B7 — пресеты-профили прав (применение к классу одним кликом)

PRESETS (student): «Полный доступ», «Режим фокуса» (без магазина/испытаний),
«Ограниченный» (+ без лаборатории), «Сбросить к стандарту роли». GET
/api/permissions/presets + POST /api/permissions/class/:id/preset (admin).
Рефактор: общий applyPermsToClass() (карта key→1/0/inherit) — его используют и
bulk, и preset. В блоке «Массово по классу» — кнопки пресетов (с подтверждением).
Тест: список + применение focus/reset + валидация. Backend pass (3 baseline-Auth).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 14:33:25 +03:00
parent b95b639e75
commit 8b495f1508
5 changed files with 110 additions and 21 deletions
@@ -157,34 +157,69 @@ function resetUserPermissions(req, res) {
res.json({ ok: true });
}
/* ── POST /api/permissions/class/:id/bulk { permission, enabled } ──────────
Выставить личное правило (user_permissions) сразу всем ученикам класса.
enabled: 1 (вкл) | 0 (выкл) | null/'inherit' (снять оверрайд → наследование роли). */
function setClassPermission(req, res) {
const cid = Number(req.params.id);
const { permission } = req.body || {};
let { enabled } = req.body || {};
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id класса' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === 'student'))
return res.status(400).json({ error: 'Unknown student permission' });
/* ── Пресеты-профили (студенческие) — применяются к классу одним кликом ──── */
const PRESETS = {
student: [
{ id: 'full', label: 'Полный доступ', desc: 'Все возможности ученика включены',
perms: { 'tests.free': 1, 'board.post': 1, 'profile.edit': 1, 'shop.purchase': 1, 'gamification.challenges': 1, 'theory.access': 1, 'simulations.access': 1, 'simulations.quiz': 1 } },
{ id: 'focus', label: 'Режим фокуса', desc: 'Без магазина и испытаний — меньше отвлечений',
perms: { 'shop.purchase': 0, 'gamification.challenges': 0 } },
{ id: 'restricted', label: 'Ограниченный', desc: 'Без магазина, испытаний и лаборатории',
perms: { 'shop.purchase': 0, 'gamification.challenges': 0, 'simulations.access': 0 } },
{ id: 'reset', label: 'Сбросить к стандарту роли', desc: 'Снять все личные правила (наследование роли)',
perms: { 'tests.free': 'inherit', 'board.post': 'inherit', 'profile.edit': 'inherit', 'shop.purchase': 'inherit', 'gamification.challenges': 'inherit', 'theory.access': 'inherit', 'simulations.access': 'inherit', 'simulations.quiz': 'inherit' } },
],
};
/* Применить карту прав { key: 1|0|'inherit' } всем ученикам класса. → число затронутых. */
function applyPermsToClass(cid, permsMap) {
const members = db.prepare(`
SELECT u.id FROM class_members cm JOIN users u ON u.id = cm.user_id
WHERE cm.class_id = ? AND u.role IN ('student','free_student')`).all(cid);
const inherit = (enabled === null || enabled === undefined || enabled === 'inherit');
const val = (enabled === 1 || enabled === true || enabled === '1') ? 1 : 0;
const del = db.prepare('DELETE FROM user_permissions WHERE user_id = ? AND permission = ?');
const up = db.prepare('INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)');
const bump = db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?');
db.transaction(() => {
for (const m of members) {
if (inherit) del.run(m.id, permission); else up.run(m.id, permission, val);
for (const [key, v] of Object.entries(permsMap)) {
if (v === 'inherit' || v === null) del.run(m.id, key);
else up.run(m.id, key, (v === 1 || v === true || v === '1') ? 1 : 0);
}
bump.run(m.id); // user-level: точечно обновляем сессию каждого затронутого ученика
}
})();
audit(req, 'permission.class_bulk', `class:${cid}/${permission}`, inherit ? 'inherit' : `enabled=${val}`);
res.json({ ok: true, affected: members.length });
return members.length;
}
/* ── POST /api/permissions/class/:id/bulk { permission, enabled } ──────────
Выставить ОДНО личное правило сразу всем ученикам класса. */
function setClassPermission(req, res) {
const cid = Number(req.params.id);
const { permission } = req.body || {};
const { enabled } = req.body || {};
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id класса' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === 'student'))
return res.status(400).json({ error: 'Unknown student permission' });
const inherit = (enabled === null || enabled === undefined || enabled === 'inherit');
const affected = applyPermsToClass(cid, { [permission]: inherit ? 'inherit' : ((enabled === 1 || enabled === true || enabled === '1') ? 1 : 0) });
audit(req, 'permission.class_bulk', `class:${cid}/${permission}`, inherit ? 'inherit' : `enabled=${enabled ? 1 : 0}`);
res.json({ ok: true, affected });
}
/* ── GET /api/permissions/presets → { student:[{id,label,desc,perms}] } ──── */
function getPresets(_req, res) { res.json(PRESETS); }
/* ── POST /api/permissions/class/:id/preset { preset } ─────────────────────
Применить пресет-профиль ко всем ученикам класса. */
function applyClassPreset(req, res) {
const cid = Number(req.params.id);
const { preset } = req.body || {};
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id класса' });
const p = PRESETS.student.find(x => x.id === preset);
if (!p) return res.status(400).json({ error: 'Unknown preset' });
const affected = applyPermsToClass(cid, p.perms);
audit(req, 'permission.class_preset', `class:${cid}`, p.id);
res.json({ ok: true, affected, preset: p.id });
}
/* ── GET /api/permissions/log?user_id= — история изменений прав (admin) ── */
@@ -225,4 +260,4 @@ function getPermissionLog(req, res) {
res.json(out);
}
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission };
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission, getPresets, applyClassPreset };
+4 -2
View File
@@ -1,6 +1,6 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { getPermissions, setPermission, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission } = require('../controllers/permissionsController');
const { getPermissions, setPermission, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission, getPresets, applyClassPreset } = require('../controllers/permissionsController');
router.use(authMiddleware);
@@ -13,8 +13,10 @@ router.get('/', getPermissions);
router.get('/log', getPermissionLog);
router.post('/', setPermission);
/* ── Массово по классу (личные оверрайды всем ученикам класса) ── */
/* ── Пресеты-профили + массово по классу (личные оверрайды ученикам класса) ── */
router.get('/presets', requireRole('admin'), getPresets);
router.post('/class/:id/bulk', requireRole('admin'), setClassPermission);
router.post('/class/:id/preset', requireRole('admin'), applyClassPreset);
/* ── Per-user overrides ── */
router.get('/users/:id', getUserPermissions);