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
+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;