feat(access): вид «по классу», массовые действия, бейджи состояния + чистка orphan-правил

По итогам ревью системы прав:
- админка: переключатель режимов «По контенту» / «По классу»
- кнопки «Открыть всем классам» / «Закрыть у всех» (и зеркально по классу)
- бейджи N/M (сколько классов открыто) в списке контента
- эндпоинты /api/access/summary и /api/access/class/:id
- вкладка «Доступ к учебникам» перенесена к «Права доступа» (группа Пользователи)
- чистка content_access при удалении класса/ученика (нет FK)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 12:47:05 +03:00
parent 5dc9164ee3
commit 76df3b4594
6 changed files with 306 additions and 99 deletions
@@ -480,6 +480,8 @@ const _deleteUserTx = db.transaction((uid) => {
// The rest cascades via ON DELETE CASCADE, but explicitly clean large tables:
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid);
db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid);
// Персональные правила доступа к контенту (нет FK — чистим вручную):
db.prepare("DELETE FROM content_access WHERE scope = 'student' AND target_id = ?").run(uid);
db.prepare('DELETE FROM users WHERE id = ?').run(uid);
});
@@ -324,6 +324,8 @@ function deleteClass(req, res) {
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
stmts.deleteClass.run(req.params.id);
// Правила доступа к контенту для этого класса (нет FK — чистим вручную):
db.prepare("DELETE FROM content_access WHERE scope = 'class' AND target_id = ?").run(req.params.id);
res.json({ ok: true });
}
+47
View File
@@ -76,6 +76,36 @@ router.get('/targets', (req, res) => {
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 textbooks = {}, exams = {};
for (const r of rows) {
if (r.content_type === 'textbook') textbooks[r.content_ref] = r.n;
else exams[r.content_ref] = r.n;
}
res.json({ totalClasses, textbooks, exams });
});
/* ── Текущие правила для одного контента ───────────────────────────────── */
/* GET /api/access/rules?content_type=&content_ref=
→ { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */
@@ -101,6 +131,23 @@ router.get('/rules', (req, res) => {
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 textbooks = [], exams = [];
for (const r of rows) (r.content_type === 'textbook' ? textbooks : exams).push(r.content_ref);
res.json({ textbooks, exams });
});
function teacherCanManageStudent(teacherId, studentId) {
const inClass = db.prepare(`
SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id