Files
Learn_System/backend/src/routes/lab.js
T
Maxim Dolgolyov 9a145e5d62 feat(access): Фаза 1a — видимость симуляций по классам (добавочная модель)
Миграция 051: расширяет content_access.content_type на 'course'/'sim' (пересборка
таблицы — SQLite не умеет ALTER CHECK) + мост «открыть все включённые симуляции
всем существующим классам» → текущее поведение не меняется. GET /api/lab/sims
теперь фильтрует список для НЕпривилегированных по allowedRefs(uid,'sim'); admin/
teacher видят все. Ролевой simulations.access остаётся «модуль вкл.» (добавочно).
Тесты: lab-access (4/4, allowlist+класс+личное), lab-sims переведён на admin для
проверки полного каталога (видимость ученика — в lab-access). /api/lab в харнессе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:19:29 +03:00

308 lines
16 KiB
JavaScript

'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: { <ref_id>: [{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;