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:
@@ -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;
|
||||
Reference in New Issue
Block a user