9a145e5d62
Миграция 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>
308 lines
16 KiB
JavaScript
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;
|