feat(lab-content-engine): phase 4 - каталог симуляций в БД + API + админка

- Миграция 042_lab_sims.sql: таблица lab_sims (id, cat, title, subject, grade,
  sort_order, enabled, featured, tags JSON), сид 40 симуляций в порядке каталога
- backend/src/routes/lab.js: GET /api/lab/sims (мёрж БД + legacy-флаги, auth),
  PATCH /api/lab/sims/:id (admin), POST /api/lab/sims/reorder (admin).
  enabled зеркалится в legacy sim_disabled_ids -> lab.html без правок фронта
- server.js: монтирование /api/lab
- tests/lab-sims.test.js: 11 тестов (auth/роли/вкл-выкл+зеркало/featured/tags/
  валидация/reorder/404), все проходят; +0 к baseline (3 pre-existing)
- admin/sections/sims.js: убран захардкоженный ADMIN_SIMS, каталог из /api/lab/sims,
  тумблеры вкл-выкл и «рекомендуемая»; XSS-эскейп, иконки .ic
- plans/: Фаза 4 done + handoff

Независимое ревью: PASS, блокеров нет. route-auth lint: PATCH-роут защищён inline
requireRole('admin'). Миграция применена к живой БД.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 15:49:05 +03:00
parent 8ce4cec798
commit c1c5bafaff
8 changed files with 397 additions and 76 deletions
@@ -0,0 +1,65 @@
-- 042_lab_sims.sql — Контент-движок лаборатории, Фаза 4.
-- Каталог симуляций в БД: метаданные + оверрайды (вкл/выкл, порядок, теги,
-- рекомендуемые, курикулумные subject/grade). Источник истины каталога для
-- админки и (опционально) для /lab. Превью-SVG остаются в коде (frontend).
--
-- Совместимость: вкл/выкл также зеркалится в app_settings.sim_disabled_ids
-- на уровне API, поэтому существующая логика lab.html не ломается.
CREATE TABLE IF NOT EXISTS lab_sims (
id TEXT PRIMARY KEY, -- id симуляции ('pendulum', ...)
cat TEXT NOT NULL, -- math | phys | chem | bio | game
title TEXT NOT NULL,
subject TEXT, -- курикулум (Фаза 5), напр. 'physics'
grade INTEGER, -- класс (Фаза 5)
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = скрыта в каталоге
featured INTEGER NOT NULL DEFAULT 0, -- 1 = «рекомендуемая»
tags TEXT NOT NULL DEFAULT '[]', -- JSON-массив строк
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_lab_sims_sort ON lab_sims (sort_order);
-- Сид 40 симуляций в текущем порядке каталога /lab (из frontend SIMS).
INSERT OR IGNORE INTO lab_sims (id, cat, title, sort_order) VALUES
('graph', 'math', 'График функции', 1),
('graphtransform', 'math', 'Трансформации графиков', 2),
('geometry', 'math', 'Планиметрия', 3),
('triangle', 'math', 'Геометрия треугольника', 4),
('quadratic', 'math', 'Корни квадратного уравнения', 5),
('stereo', 'math', 'Стереометрия 3D', 6),
('probability', 'math', 'Теория вероятностей', 7),
('trigcircle', 'math', 'Тригонометрическая окружность', 8),
('normaldist', 'math', 'Нормальное распределение', 9),
('projectile', 'phys', 'Бросок тела', 10),
('pendulum', 'phys', 'Маятник', 11),
('collision', 'phys', 'Столкновение шаров', 12),
('emfield', 'phys', 'Электромагнитные поля', 13),
('circuit', 'phys', 'Электрические цепи', 14),
('hydrostatics', 'phys', 'Гидростатика', 15),
('dynamics', 'phys', 'Динамика', 16),
('opticsbench', 'phys', 'Оптическая скамья', 17),
('isoprocess', 'phys', 'Изопроцессы', 18),
('waves', 'phys', 'Волны и звук', 19),
('radioactive', 'phys', 'Радиоактивный распад', 20),
('race', 'phys', 'Гонка с задачами', 21),
('heatengine', 'phys', 'Тепловые двигатели', 22),
('logic', 'phys', 'Логические схемы', 23),
('molphys', 'chem', 'Молекулярная физика', 24),
('chemistry', 'chem', 'Химические реакции', 25),
('equilibrium', 'chem', 'Химическое равновесие', 26),
('electrolysis', 'chem', 'Электролиз', 27),
('bohratom', 'chem', 'Атом Бора', 28),
('orbitals', 'chem', 'Молекулярные орбитали', 29),
('titration', 'chem', 'pH и кривая титрования', 30),
('chemsandbox', 'chem', 'Химическая песочница', 31),
('stoichiometry', 'chem', 'Стехиометрия', 32),
('crystal', 'chem', 'Кристаллическая решётка', 33),
('qualanalysis', 'chem', 'Качественный анализ', 34),
('periodic', 'chem', 'Периодическая таблица', 35),
('organic', 'chem', 'Органическая химия', 36),
('solutions', 'chem', 'Растворы', 37),
('celldivision', 'bio', 'Деление клетки', 38),
('photosynthesis', 'bio', 'Фотосинтез и дыхание', 39),
('angrybirds', 'game', 'Angry Birds Physics', 40);
+130
View File
@@ -0,0 +1,130 @@
'use strict';
/* /api/lab — каталог симуляций лаборатории (контент-движок, Фаза 4).
*
* GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги модуля.
* Чтение: любой авторизованный пользователь.
* PATCH /api/lab/sims/:id — изменить enabled/featured/tags/subject/grade. admin.
* POST /api/lab/sims/reorder — задать порядок (массив id). admin.
*
* Совместимость: enabled зеркалится в app_settings.sim_disabled_ids, поэтому
* существующая логика lab.html (которая читает /api/settings/sims) продолжает
* корректно скрывать отключённые симуляции без правок фронта. */
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
router.use(authMiddleware);
/* ── helpers ───────────────────────────────────────────────────────────── */
function readModuleDisabled() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_module_disabled'`).get();
return row ? row.value === '1' : false;
}
function readLegacyDisabledIds() {
const row = db.prepare(`SELECT value FROM app_settings WHERE key = 'sim_disabled_ids'`).get();
try { return new Set(JSON.parse(row && row.value || '[]')); } catch { return new Set(); }
}
function writeLegacyDisabledIds(set) {
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_disabled_ids', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
.run(JSON.stringify([...set]));
}
function parseTags(raw) { try { return JSON.parse(raw || '[]'); } catch { return []; } }
function rowToSim(r) {
return {
id: r.id, cat: r.cat, title: r.title,
subject: r.subject || null, grade: r.grade != null ? r.grade : null,
sort: r.sort_order, enabled: !!r.enabled, featured: !!r.featured,
tags: parseTags(r.tags),
};
}
/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */
router.get('/sims', (_req, res) => {
const rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all();
const legacyDisabled = readLegacyDisabledIds();
const sims = rows.map(r => {
const s = rowToSim(r);
// Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке.
s.enabled = s.enabled && !legacyDisabled.has(r.id);
return s;
});
res.json({ module_disabled: readModuleDisabled(), sims });
});
/* ── admin mutations ───────────────────────────────────────────────────── */
router.use(requireRole('admin'));
/* PATCH /api/lab/sims/:id body: { enabled?, featured?, tags?, subject?, grade?, title?, cat? } */
router.patch('/sims/:id', requireRole('admin'), (req, res) => {
const id = String(req.params.id || '');
const row = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
if (!row) return res.status(404).json({ error: 'симуляция не найдена' });
const b = req.body || {};
const sets = [];
const vals = [];
if (b.enabled !== undefined) { sets.push('enabled = ?'); vals.push(b.enabled ? 1 : 0); }
if (b.featured !== undefined) { sets.push('featured = ?'); vals.push(b.featured ? 1 : 0); }
if (b.title !== undefined) {
const t = String(b.title).trim();
if (!t) return res.status(400).json({ error: 'пустой title' });
sets.push('title = ?'); vals.push(t);
}
if (b.cat !== undefined) {
if (!CATS.includes(b.cat)) return res.status(400).json({ error: 'неверная категория' });
sets.push('cat = ?'); vals.push(b.cat);
}
if (b.subject !== undefined) { sets.push('subject = ?'); vals.push(b.subject ? String(b.subject) : null); }
if (b.grade !== undefined) {
const g = b.grade === null || b.grade === '' ? null : Number(b.grade);
if (g !== null && (!Number.isInteger(g) || g < 1 || g > 11)) {
return res.status(400).json({ error: 'grade должен быть 1..11 или null' });
}
sets.push('grade = ?'); vals.push(g);
}
if (b.tags !== undefined) {
if (!Array.isArray(b.tags)) return res.status(400).json({ error: 'tags должен быть массивом' });
const clean = b.tags.map(t => String(t).trim()).filter(Boolean).slice(0, 20);
sets.push('tags = ?'); vals.push(JSON.stringify(clean));
}
if (!sets.length) return res.status(400).json({ error: 'нет полей для обновления' });
sets.push("updated_at = datetime('now')");
vals.push(id);
db.prepare(`UPDATE lab_sims SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
// Зеркалим enabled в legacy sim_disabled_ids для совместимости с lab.html.
if (b.enabled !== undefined) {
const set = readLegacyDisabledIds();
if (b.enabled) set.delete(id); else set.add(id);
writeLegacyDisabledIds(set);
}
const updated = db.prepare('SELECT * FROM lab_sims WHERE id = ?').get(id);
res.json({ ok: true, sim: rowToSim(updated) });
});
/* POST /api/lab/sims/reorder body: { order: [id, id, ...] } */
router.post('/sims/reorder', (req, res) => {
const order = (req.body && req.body.order) || [];
if (!Array.isArray(order) || !order.length) {
return res.status(400).json({ error: 'order должен быть непустым массивом id' });
}
const exists = new Set(db.prepare('SELECT id FROM lab_sims').all().map(r => r.id));
for (const id of order) {
if (!exists.has(id)) return res.status(400).json({ error: 'неизвестный id: ' + id });
}
const upd = db.prepare("UPDATE lab_sims SET sort_order = ?, updated_at = datetime('now') WHERE id = ?");
db.transaction(() => {
order.forEach((id, i) => upd.run(i + 1, id));
})();
res.json({ ok: true, count: order.length });
});
module.exports = router;
+2
View File
@@ -55,6 +55,7 @@ const examPrepRoutes = require('./routes/exam-prep');
const textbookRoutes = require('./routes/textbooks');
const accessRoutes = require('./routes/access');
const teacherStudentsRoutes = require('./routes/teacherStudents');
const labRoutes = require('./routes/lab');
const { requestId, errorHandler } = require('./middleware/errorHandler');
@@ -177,6 +178,7 @@ app.use('/api/exam-prep', examPrepRoutes);
app.use('/api/textbooks', textbookRoutes);
app.use('/api/access', accessRoutes);
app.use('/api/teacher-students', teacherStudentsRoutes);
app.use('/api/lab', labRoutes);
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
const _featDb = require('./db/db');
+122
View File
@@ -0,0 +1,122 @@
'use strict';
/**
* Integration tests: /api/lab/sims — catalog from DB + admin overrides.
* Covers: seeded catalog, auth, role-gating, enabled toggle (+legacy mirror),
* featured/tags/subject/grade patch, reorder, validation.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
// Mount /api/lab on the shared test app (setup builds its own app without it).
app.use('/api/lab', require('../src/routes/lab'));
after(() => cleanup());
describe('/api/lab/sims', () => {
let adminToken, studentToken;
before(async () => {
adminToken = (await getToken('admin')).token;
studentToken = (await getToken('student')).token;
});
it('GET /api/lab/sims requires auth (401 without token)', async () => {
const res = await inject('GET', '/api/lab/sims', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('GET /api/lab/sims returns seeded catalog (40 sims) for a student', async () => {
const res = await inject('GET', '/api/lab/sims', null, studentToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.module_disabled, false);
assert.ok(Array.isArray(res.body.sims), 'sims is array');
assert.equal(res.body.sims.length, 40, `expected 40 sims, got ${res.body.sims.length}`);
const pend = res.body.sims.find(s => s.id === 'pendulum');
assert.ok(pend, 'pendulum present');
assert.equal(pend.cat, 'phys');
assert.equal(pend.enabled, true);
assert.deepEqual(pend.tags, []);
});
it('catalog is ordered by sort_order (graph first, angrybirds last)', async () => {
const res = await inject('GET', '/api/lab/sims', null, studentToken);
assert.equal(res.body.sims[0].id, 'graph');
assert.equal(res.body.sims[res.body.sims.length - 1].id, 'angrybirds');
});
it('PATCH /api/lab/sims/:id is admin-only (student → 403)', async () => {
const res = await inject('PATCH', '/api/lab/sims/pendulum', { featured: true }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
it('admin can disable a sim; it reflects in GET and in legacy sim_disabled_ids', async () => {
const res = await inject('PATCH', '/api/lab/sims/waves', { enabled: false }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.sim.enabled, false);
const get = await inject('GET', '/api/lab/sims', null, adminToken);
const waves = get.body.sims.find(s => s.id === 'waves');
assert.equal(waves.enabled, false, 'waves disabled in catalog');
const legacy = JSON.parse(
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
);
assert.ok(legacy.includes('waves'), 'waves in legacy sim_disabled_ids');
await inject('PATCH', '/api/lab/sims/waves', { enabled: true }, adminToken);
const legacy2 = JSON.parse(
db.prepare("SELECT value FROM app_settings WHERE key='sim_disabled_ids'").get().value
);
assert.ok(!legacy2.includes('waves'), 'waves removed from legacy after enable');
});
it('admin can set featured, tags, subject, grade', async () => {
const res = await inject('PATCH', '/api/lab/sims/pendulum',
{ featured: true, tags: ['колебания', 'механика'], subject: 'physics', grade: 9 }, adminToken);
assert.equal(res.status, 200);
assert.equal(res.body.sim.featured, true);
assert.deepEqual(res.body.sim.tags, ['колебания', 'механика']);
assert.equal(res.body.sim.subject, 'physics');
assert.equal(res.body.sim.grade, 9);
});
it('PATCH rejects bad grade and bad category and non-array tags', async () => {
const g = await inject('PATCH', '/api/lab/sims/pendulum', { grade: 99 }, adminToken);
assert.equal(g.status, 400, 'bad grade rejected');
const c = await inject('PATCH', '/api/lab/sims/pendulum', { cat: 'nope' }, adminToken);
assert.equal(c.status, 400, 'bad cat rejected');
const t = await inject('PATCH', '/api/lab/sims/pendulum', { tags: 'notarray' }, adminToken);
assert.equal(t.status, 400, 'non-array tags rejected');
});
it('PATCH unknown sim → 404', async () => {
const res = await inject('PATCH', '/api/lab/sims/nonexistent', { featured: true }, adminToken);
assert.equal(res.status, 404, `got ${res.status}`);
});
it('POST /api/lab/sims/reorder updates sort order (admin)', async () => {
const get = await inject('GET', '/api/lab/sims', null, adminToken);
const ids = get.body.sims.map(s => s.id);
const reordered = ['angrybirds', 'graph', ...ids.filter(id => id !== 'angrybirds' && id !== 'graph')];
const res = await inject('POST', '/api/lab/sims/reorder', { order: reordered }, adminToken);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.count, 40);
const get2 = await inject('GET', '/api/lab/sims', null, adminToken);
assert.equal(get2.body.sims[0].id, 'angrybirds', 'angrybirds now first');
assert.equal(get2.body.sims[1].id, 'graph', 'graph now second');
});
it('reorder rejects unknown id and empty order', async () => {
const bad = await inject('POST', '/api/lab/sims/reorder', { order: ['ghost'] }, adminToken);
assert.equal(bad.status, 400, 'unknown id rejected');
const empty = await inject('POST', '/api/lab/sims/reorder', { order: [] }, adminToken);
assert.equal(empty.status, 400, 'empty order rejected');
});
it('reorder is admin-only (student → 403)', async () => {
const res = await inject('POST', '/api/lab/sims/reorder', { order: ['graph'] }, studentToken);
assert.equal(res.status, 403, `got ${res.status}`);
});
});