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);
+28
View File
@@ -246,4 +246,32 @@ describe('Permissions', () => {
{ permission: 'questions.manage', enabled: true }, adminToken);
assert.equal(bad.status, 400);
});
// ── B7: пресеты-профили ────────────────────────────────────────────────────
it('B7: пресеты — список + применение к классу + валидация', async () => {
const list = await inject('GET', '/api/permissions/presets', null, adminToken);
assert.equal(list.status, 200);
assert.ok(Array.isArray(list.body.student) && list.body.student.some(p => p.id === 'focus'));
const cr = await inject('POST', '/api/classes', { name: 'PresetClass' }, adminToken);
assert.ok(cr.status < 300);
const cid = db.prepare('SELECT id FROM classes WHERE name = ?').get('PresetClass').id;
await inject('POST', `/api/classes/${cid}/members`, { user_id: studentUser.userId }, adminToken);
// focus: shop.purchase=0, gamification.challenges=0
const ap = await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'focus' }, adminToken);
assert.equal(ap.status, 200);
assert.ok(ap.body.affected >= 1);
const shop = db.prepare('SELECT enabled FROM user_permissions WHERE user_id=? AND permission=?')
.get(studentUser.userId, 'shop.purchase');
assert.ok(shop && shop.enabled === 0, 'focus выключил магазин');
// reset: снимает все оверрайды
await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'reset' }, adminToken);
const left = db.prepare("SELECT COUNT(*) n FROM user_permissions WHERE user_id=?").get(studentUser.userId).n;
assert.equal(left, 0, 'reset снял все личные правила');
const badP = await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'nope' }, adminToken);
assert.equal(badP.status, 400);
});
});