'use strict'; /* /api/lab — каталог симуляций лаборатории (контент-движок, Фазы 4-5). * * GET /api/lab/sims — каталог из БД (lab_sims) + legacy-флаги. auth. * PATCH /api/lab/sims/:id — enabled/featured/tags/subject/grade. admin. * POST /api/lab/sims/reorder — задать порядок (массив id). admin. * GET /api/lab/sims/:id/related — связанные § / темы / kmap / задачи. auth. (Ф5) * POST /api/lab/sims/:id/links — добавить связь. admin. (Ф5) * DELETE /api/lab/sims/:id/links/:linkId — удалить связь. admin. (Ф5) * GET /api/lab/links?kind=&ref_id= — обратный поиск: какие симуляции привязаны * к данному учебнику/теме. auth. (Ф5) * * Совместимость: 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 access = require('../services/contentAccess'); const CATS = ['math', 'phys', 'chem', 'bio', 'game']; const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question']; 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) => { let rows; try { rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all(); } catch (e) { // Деградация вместо 500: если миграция lab_sims ещё не применена на этом // инстансе (старый процесс/другая БД) — отдаём пустой каталог, чтобы админка // не падала. Нужно применить миграцию и перезапустить сервер. console.warn('[lab] lab_sims недоступна (нужна миграция/перезапуск):', e.message); return res.json({ module_disabled: readModuleDisabled(), sims: [], needs_migration: true }); } const legacyDisabled = readLegacyDisabledIds(); let sims = rows.map(r => { const s = rowToSim(r); // Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке. s.enabled = s.enabled && !legacyDisabled.has(r.id); return s; }); // Видимость по классам (добавочная модель): ролевой simulations.access решает, // включён ли модуль вообще (проверяется на фронте/при действиях); здесь ученик // видит только разрешённые его классу/лично симуляции. admin/teacher — все. if (req.user && !access.PRIVILEGED.has(req.user.role)) { const allowed = access.allowedRefs(req.user.id, 'sim'); sims = sims.filter(s => allowed.has(s.id)); } res.json({ module_disabled: readModuleDisabled(), sims }); }); /* ── admin mutations ─────────────────────────────────────────────────────── ВАЖНО: НЕ используем blanket `router.use(requireRole('admin'))` — он применялся бы и к ниже определённым READ-роутам Фазы 5 (/related, /links), которые должны быть доступны любому авторизованному пользователю. Каждая мутация защищена INLINE requireRole('admin') (так же видит route-auth линтер). */ /* 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', requireRole('admin'), (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 }); }); /* ════════════════════════════════════════════════════════════════════════ Курикулумная привязка (Фаза 5) — связи симуляции ↔ контент. ════════════════════════════════════════════════════════════════════════ */ // Безопасно прочитать связи симуляции (если таблицы ещё нет — пустой массив). function readLinks(simId) { try { return db.prepare( 'SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE sim_id = ? ORDER BY kind, id' ).all(simId); } catch (e) { return null; // null => таблица недоступна (нужна миграция) } } // Обогатить связь человекочитаемой меткой и навигационным href. function decorateLink(l) { const out = { id: l.id, kind: l.kind, ref_id: l.ref_id, label: l.label || null }; if (l.kind === 'textbook') { const tb = db.prepare('SELECT title, subject, grade FROM textbooks WHERE slug = ?').get(l.ref_id); if (tb) { out.label = out.label || tb.title; out.subject = tb.subject; out.grade = tb.grade; } out.href = '/textbooks?book=' + encodeURIComponent(l.ref_id); } else if (l.kind === 'topic') { const tp = db.prepare('SELECT name FROM topics WHERE id = ?').get(Number(l.ref_id)); if (tp) out.label = out.label || tp.name; } else if (l.kind === 'question') { out.href = null; // задачи открываются в банке вопросов отдельным контекстом } if (!out.label) out.label = l.kind + ':' + l.ref_id; return out; } /* GET /api/lab/sims/:id/related → { sim, links:{ textbook:[], topic:[], kmap:[], question:[] } } */ router.get('/sims/:id/related', authMiddleware, (req, res) => { const id = String(req.params.id || ''); const sim = db.prepare('SELECT id, title FROM lab_sims WHERE id = ?').get(id); // sim может отсутствовать в lab_sims (если миграция 042 не применена) — не 404, // т.к. связи всё равно могут существовать; вернём то, что есть. const rows = readLinks(id); if (rows === null) return res.json({ sim: sim || { id }, links: {}, needs_migration: true }); const links = { textbook: [], topic: [], kmap: [], question: [] }; for (const l of rows) { const d = decorateLink(l); (links[l.kind] || (links[l.kind] = [])).push(d); } res.json({ sim: sim || { id }, links }); }); /* GET /api/lab/links?kind=textbook&ref_id=algebra-8 → { sims:[{id,title,cat,enabled}] } — какие (включённые) симуляции привязаны. */ router.get('/links', (req, res) => { const kind = String(req.query.kind || ''); const refId = String(req.query.ref_id || ''); if (!LINK_KINDS.includes(kind) || !refId) { return res.status(400).json({ error: 'kind и ref_id обязательны' }); } let rows; try { rows = db.prepare(` SELECT s.id, s.title, s.cat, s.enabled FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id WHERE l.kind = ? AND l.ref_id = ? ORDER BY s.sort_order, s.id `).all(kind, refId); } catch (e) { return res.json({ sims: [], needs_migration: true }); } const legacyDisabled = readLegacyDisabledIds(); const sims = rows .map(r => ({ id: r.id, title: r.title, cat: r.cat, enabled: !!r.enabled && !legacyDisabled.has(r.id) })) .filter(s => s.enabled); // наружу отдаём только доступные res.json({ sims }); }); /* GET /api/lab/links/all?kind=textbook → { byRef: { : [{id,title,cat}] } } — пакетный обратный поиск для всех ref_id данного типа за один запрос (избегаем N+1 на странице каталога учебников). Отдаёт только включённые симуляции. */ router.get('/links/all', (req, res) => { const kind = String(req.query.kind || ''); if (!LINK_KINDS.includes(kind)) { return res.status(400).json({ error: 'неверный kind' }); } let rows; try { rows = db.prepare(` SELECT l.ref_id, s.id, s.title, s.cat, s.enabled, s.sort_order FROM lab_sim_links l JOIN lab_sims s ON s.id = l.sim_id WHERE l.kind = ? ORDER BY s.sort_order, s.id `).all(kind); } catch (e) { return res.json({ byRef: {}, needs_migration: true }); } const legacyDisabled = readLegacyDisabledIds(); const byRef = {}; for (const r of rows) { if (!r.enabled || legacyDisabled.has(r.id)) continue; (byRef[r.ref_id] || (byRef[r.ref_id] = [])).push({ id: r.id, title: r.title, cat: r.cat }); } res.json({ byRef }); }); /* ── admin: управление связями ─────────────────────────────────────────── */ /* POST /api/lab/sims/:id/links body: { kind, ref_id, label? } */ router.post('/sims/:id/links', requireRole('admin'), (req, res) => { const simId = String(req.params.id || ''); if (!db.prepare('SELECT 1 FROM lab_sims WHERE id = ?').get(simId)) { return res.status(404).json({ error: 'симуляция не найдена' }); } const b = req.body || {}; const kind = String(b.kind || ''); const refId = String(b.ref_id || '').trim(); if (!LINK_KINDS.includes(kind)) return res.status(400).json({ error: 'неверный kind' }); if (!refId) return res.status(400).json({ error: 'ref_id обязателен' }); // Валидация существования цели (мягкая — kmap/question произвольны). if (kind === 'textbook' && !db.prepare('SELECT 1 FROM textbooks WHERE slug = ?').get(refId)) { return res.status(404).json({ error: 'учебник не найден: ' + refId }); } if (kind === 'topic') { const tid = Number(refId); if (!Number.isInteger(tid) || !db.prepare('SELECT 1 FROM topics WHERE id = ?').get(tid)) { return res.status(404).json({ error: 'тема не найдена: ' + refId }); } } const label = b.label != null ? String(b.label).trim().slice(0, 200) || null : null; try { const info = db.prepare( 'INSERT INTO lab_sim_links (sim_id, kind, ref_id, label, created_by) VALUES (?, ?, ?, ?, ?)' ).run(simId, kind, refId, label, req.user.id); const created = db.prepare('SELECT id, sim_id, kind, ref_id, label FROM lab_sim_links WHERE id = ?') .get(info.lastInsertRowid); res.json({ ok: true, link: decorateLink(created) }); } catch (e) { if (/UNIQUE/i.test(e.message)) return res.status(409).json({ error: 'такая связь уже есть' }); throw e; } }); /* DELETE /api/lab/sims/:id/links/:linkId */ router.delete('/sims/:id/links/:linkId', requireRole('admin'), (req, res) => { const simId = String(req.params.id || ''); const linkId = Number(req.params.linkId); if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' }); const info = db.prepare('DELETE FROM lab_sim_links WHERE id = ? AND sim_id = ?').run(linkId, simId); if (!info.changes) return res.status(404).json({ error: 'связь не найдена' }); res.json({ ok: true }); }); module.exports = router;