feat(access): Фаза 1b — управление доступом к симуляциям в админке

Бэкенд /api/access обобщён на тип 'sim': catalog отдаёт симуляции (lab_sims),
summary/matrix/class — карты по всем типам. Админ-секция «Доступ» теперь
показывает «Симуляции» во всех трёх режимах (по контенту / по классу / матрица)
+ поиск; helpers (bucket/keyName/itemsOf) обобщены через карты типов
(CONTENT_TYPES=textbook,exam,sim; course зарезервирован). Теперь админ/учитель
могут открывать/закрывать конкретные симуляции классам и ученикам — закрыт UX-
разрыв из 1a (новые классы без UI-управления). Тест: каталог включает sims; 210 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 13:24:08 +03:00
parent 9a145e5d62
commit 4549b4e819
3 changed files with 64 additions and 47 deletions
+17 -11
View File
@@ -29,7 +29,16 @@ router.get('/catalog', (_req, res) => {
WHERE enabled = 1
ORDER BY sort_order, exam_key
`).all();
res.json({ textbooks, exams });
let sims = [];
try {
sims = db.prepare(`
SELECT id, title, cat AS subject, grade
FROM lab_sims
WHERE enabled = 1
ORDER BY sort_order, id
`).all();
} catch (_e) { /* lab_sims может отсутствовать на старом инстансе — деградация */ }
res.json({ textbooks, exams, sims });
});
/* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */
@@ -99,12 +108,9 @@ router.get('/summary', (req, res) => {
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 });
const out = { textbook: {}, exam: {}, sim: {}, course: {} };
for (const r of rows) { (out[r.content_type] || (out[r.content_type] = {}))[r.content_ref] = r.n; }
res.json({ totalClasses, textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course });
});
/* ── Матрица класс × контент (обзор и правка одним экраном) ────────────── */
@@ -116,7 +122,7 @@ router.get('/matrix', (req, res) => {
? db.prepare('SELECT id, name FROM classes ORDER BY name').all()
: db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(req.user.id);
const open = {};
classes.forEach(c => { open[c.id] = { textbook: [], exam: [] }; });
classes.forEach(c => { open[c.id] = { textbook: [], exam: [], sim: [], course: [] }; });
const ids = classes.map(c => c.id);
if (ids.length) {
const ph = ids.map(() => '?').join(',');
@@ -169,9 +175,9 @@ router.get('/class/:id', requireRole('admin', 'teacher'), (req, res) => {
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 });
const out = { textbook: [], exam: [], sim: [], course: [] };
for (const r of rows) (out[r.content_type] || (out[r.content_type] = [])).push(r.content_ref);
res.json({ textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course });
});
function teacherCanManageStudent(teacherId, studentId) {
const inClass = db.prepare(`
+6
View File
@@ -110,6 +110,12 @@ describe('contentAccess', () => {
assert.equal(r.status, 403);
});
it('GET /api/access/catalog включает симуляции', async () => {
const r = await inject('GET', '/api/access/catalog', null, teacher.token);
assert.equal(r.status, 200);
assert.ok(Array.isArray(r.body.sims) && r.body.sims.length >= 1, 'каталог содержит симуляции');
});
it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => {
setRule('class', classId, 1);
const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token);
+41 -36
View File
@@ -34,9 +34,14 @@
russian: 'Русский язык', english: 'Английский', geography: 'География', history: 'История' };
const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
const bucket = (type) => (type === 'textbook' ? 'textbooks' : 'exams');
const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key');
const itemsOf = (type) => (type === 'textbook' ? _catalog.textbooks : _catalog.exams) || [];
const BUCKET = { textbook: 'textbooks', exam: 'exams', sim: 'sims', course: 'courses' };
const KEYNAME = { textbook: 'slug', exam: 'exam_key', sim: 'id', course: 'id' };
const TYPE_LABEL = { textbook: 'Учебники', exam: 'Экзамены', sim: 'Симуляции', course: 'Курсы' };
const TYPE_BADGE = { textbook: 'Учебник', exam: 'Экзамен', sim: 'Симуляция', course: 'Курс' };
const CONTENT_TYPES = ['textbook', 'exam', 'sim']; // course добавим отдельным шагом
const bucket = (type) => BUCKET[type] || (type + 's');
const keyName = (type) => KEYNAME[type] || 'id';
const itemsOf = (type) => (_catalog && _catalog[bucket(type)]) || [];
function contentTitle(type, ref) {
const it = itemsOf(type).find(x => x[keyName(type)] === ref);
return it ? it.title : ref;
@@ -105,25 +110,25 @@
const total = _summary.totalClasses || 0;
const term = _leftSearch.trim().toLowerCase();
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
const tbs = (_catalog.textbooks || []).filter(match);
const exs = (_catalog.exams || []).filter(match);
let html = '';
if (tbs.length) {
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Учебники</div>`;
let lastSubj = null;
tbs.forEach(it => {
const sj = it.subject || '';
if (sj !== lastSubj) {
lastSubj = sj;
html += `<div style="font-size:10.5px;color:var(--muted);padding:6px 10px 2px;text-transform:uppercase;letter-spacing:.04em">${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}</div>`;
}
html += contentItemBtn('textbook', it, total);
});
}
if (exs.length) {
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">Экзамены</div>`;
exs.forEach(it => { html += contentItemBtn('exam', it, total); });
}
CONTENT_TYPES.forEach(type => {
const items = itemsOf(type).filter(match);
if (!items.length) return;
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">${TYPE_LABEL[type]}</div>`;
if (type === 'textbook') {
let lastSubj = null;
items.forEach(it => {
const sj = it.subject || '';
if (sj !== lastSubj) {
lastSubj = sj;
html += `<div style="font-size:10.5px;color:var(--muted);padding:6px 10px 2px;text-transform:uppercase;letter-spacing:.04em">${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}</div>`;
}
html += contentItemBtn('textbook', it, total);
});
} else {
items.forEach(it => { html += contentItemBtn(type, it, total); });
}
});
return html || empty('Ничего не найдено');
}
function leftSearch(v) {
@@ -250,7 +255,7 @@
right.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px">
<div style="font-size:16px;font-weight:700;color:var(--text-1)">${esc(_selContent.title)}</div>
<span class="badge ${_selContent.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${_selContent.type === 'exam' ? 'Экзамен' : 'Учебник'}</span>
<span class="badge ${_selContent.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${TYPE_BADGE[_selContent.type] || 'Контент'}</span>
</div>
${classes.length ? `
<div style="display:flex;gap:8px;margin-bottom:14px">
@@ -318,7 +323,8 @@
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
try {
const open = await LS.accessClassOpen(id);
_classOpen = { textbooks: new Set(open.textbooks || []), exams: new Set(open.exams || []) };
_classOpen = {};
CONTENT_TYPES.forEach(t => { _classOpen[bucket(t)] = new Set(open[bucket(t)] || []); });
renderRight();
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
}
@@ -329,7 +335,7 @@
return `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
<span style="font-size:13.5px;color:var(--text-1)">${esc(it.title)}
<span class="badge ${type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:10.5px;margin-left:6px">${type === 'exam' ? 'Экзамен' : 'Учебник'}</span></span>
<span class="badge ${type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:10.5px;margin-left:6px">${TYPE_BADGE[type] || 'Контент'}</span></span>
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
<span>${open ? 'Открыт' : 'Закрыт'}</span>
<input type="checkbox" ${open ? 'checked' : ''} onchange="accClassToggle('${type}','${esc(ref)}', this.checked)">
@@ -338,17 +344,18 @@
}
function renderClassDetail(right) {
const tb = _catalog.textbooks || [], ex = _catalog.exams || [];
right.innerHTML = `
let html = `
<div style="font-size:16px;font-weight:700;color:var(--text-1);margin-bottom:14px">Класс «${esc(_selClass.name)}»</div>
<div style="display:flex;gap:8px;margin-bottom:14px">
<button class="adm-btn adm-btn-small" onclick="accClassBulk(1)">Открыть весь контент</button>
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accClassBulk(0)">Закрыть весь</button>
</div>
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:4px 0 8px">Учебники</div>
${tb.length ? tb.map(it => classContentRow('textbook', it)).join('') : empty('Нет учебников')}
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">Экзамены</div>
${ex.length ? ex.map(it => classContentRow('exam', it)).join('') : empty('Нет экзаменов')}`;
</div>`;
CONTENT_TYPES.forEach(type => {
const items = itemsOf(type);
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">${TYPE_LABEL[type]}</div>`;
html += items.length ? items.map(it => classContentRow(type, it)).join('') : empty('Нет');
});
right.innerHTML = html;
}
function bumpSummary(type, ref, delta) {
@@ -371,8 +378,7 @@
async function classBulk(allow) {
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
const all = [...(_catalog.textbooks || []).map(it => ['textbook', it[keyName('textbook')]]),
...(_catalog.exams || []).map(it => ['exam', it[keyName('exam')]])];
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
try {
await Promise.all(all.map(([type, ref]) =>
LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null)));
@@ -407,10 +413,9 @@
return `<tr><th scope="row" style="text-align:left;padding:6px 10px;font-size:13px;font-weight:500;color:var(--text-1);white-space:nowrap;position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border-soft,#f0f0f0)">${esc(it.title)}</th>${cells}</tr>`;
}).join('');
if (!rows) return '';
const label = type === 'textbook' ? 'Учебники' : 'Экзамены';
return `<tr><th colspan="${classes.length + 1}" style="text-align:left;padding:10px 10px 4px;font-size:12px;font-weight:700;color:var(--text-3)">${label}</th></tr>${rows}`;
return `<tr><th colspan="${classes.length + 1}" style="text-align:left;padding:10px 10px 4px;font-size:12px;font-weight:700;color:var(--text-3)">${TYPE_LABEL[type] || type}</th></tr>${rows}`;
};
const body = section('textbook', (_catalog || {}).textbooks) + section('exam', (_catalog || {}).exams);
const body = CONTENT_TYPES.map(t => section(t, itemsOf(t))).join('');
return body || `<tr><td colspan="${classes.length + 1}" style="padding:10px">${empty('Ничего не найдено')}</td></tr>`;
}
async function renderMatrix() {