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');