diff --git a/backend/src/db/migrations/043_lab_sim_links.sql b/backend/src/db/migrations/043_lab_sim_links.sql new file mode 100644 index 0000000..3421f67 --- /dev/null +++ b/backend/src/db/migrations/043_lab_sim_links.sql @@ -0,0 +1,24 @@ +-- 043_lab_sim_links.sql — Контент-движок лаборатории, Фаза 5 (курикулумная привязка). +-- Связи симуляции с учебной программой: § учебника, тема, узел knowledge-map, +-- задача банка вопросов. Двусторонняя навигация (sim ↔ контент). +-- +-- kind: +-- 'textbook' — ref_id = textbooks.slug +-- 'topic' — ref_id = topics.id (как текст) +-- 'kmap' — ref_id = id узла графа знаний (свободная строка) +-- 'question' — ref_id = questions.id (как текст) +-- label — необязательная человекочитаемая подпись (если не резолвится из БД). + +CREATE TABLE IF NOT EXISTS lab_sim_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sim_id TEXT NOT NULL, + kind TEXT NOT NULL, -- textbook | topic | kmap | question + ref_id TEXT NOT NULL, + label TEXT, + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (sim_id, kind, ref_id) +); + +CREATE INDEX IF NOT EXISTS idx_lab_sim_links_sim ON lab_sim_links (sim_id); +CREATE INDEX IF NOT EXISTS idx_lab_sim_links_ref ON lab_sim_links (kind, ref_id); diff --git a/backend/src/routes/lab.js b/backend/src/routes/lab.js index 55070c6..9dfba4f 100644 --- a/backend/src/routes/lab.js +++ b/backend/src/routes/lab.js @@ -1,10 +1,14 @@ 'use strict'; -/* /api/lab — каталог симуляций лаборатории (контент-движок, Фаза 4). +/* /api/lab — каталог симуляций лаборатории (контент-движок, Фазы 4-5). * - * 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. + * 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) продолжает @@ -14,6 +18,7 @@ const db = require('../db/db'); const { authMiddleware, requireRole } = require('../middleware/auth'); const CATS = ['math', 'phys', 'chem', 'bio', 'game']; +const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question']; router.use(authMiddleware); @@ -136,4 +141,127 @@ router.post('/sims/reorder', (req, res) => { 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 }); +}); + +/* ── 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; diff --git a/backend/tests/lab-links.test.js b/backend/tests/lab-links.test.js new file mode 100644 index 0000000..39e281c --- /dev/null +++ b/backend/tests/lab-links.test.js @@ -0,0 +1,174 @@ +'use strict'; +/** + * Integration tests: /api/lab — curriculum links (Phase 5). + * Covers: related (auth), reverse lookup, admin add/delete, validation, + * role-gating, textbook/topic existence checks, enabled-filtering of reverse. + */ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { app, db, inject, getToken, cleanup } = require('./setup'); + +// Mount /api/lab on the shared test app. +app.use('/api/lab', require('../src/routes/lab')); + +after(() => cleanup()); + +/** + * Schema-robust insert: fills every NOT NULL column (without a default) that the + * caller didn't provide with a safe placeholder, then inserts. Returns lastInsertRowid. + * Protects the seed from schema drift (e.g. textbooks.html_path NOT NULL) introduced + * by parallel sessions on this branch. + */ +function seedRow(table, provided) { + const cols = db.prepare(`PRAGMA table_info(${table})`).all(); + const row = { ...provided }; + for (const c of cols) { + if (c.pk) continue; // skip primary key (autoincrement) + if (c.name in row) continue; // caller-provided + if (c.notnull && c.dflt_value === null) { // required, no default → fill placeholder + row[c.name] = /INT|REAL|NUM/i.test(c.type) ? 0 : ''; + } + } + const names = Object.keys(row); + const ph = names.map(() => '?').join(', '); + const info = db.prepare(`INSERT INTO ${table} (${names.join(', ')}) VALUES (${ph})`) + .run(...names.map(n => row[n])); + return info.lastInsertRowid; +} + +describe('/api/lab curriculum links', () => { + let adminToken, studentToken, tbSlug, topicId; + + before(async () => { + adminToken = (await getToken('admin')).token; + studentToken = (await getToken('student')).token; + // Seed a textbook + topic to link against. + db.prepare(`INSERT INTO textbooks (slug, title, subject, grade, is_active) + VALUES ('phys-test', 'Физика тест', 'physics', 9, 1) + ON CONFLICT(slug) DO NOTHING`).run(); + tbSlug = 'phys-test'; + const subj = db.prepare(`INSERT INTO subjects (name) VALUES ('LinkTest Subj')`).run(); + const tp = db.prepare(`INSERT INTO topics (subject_id, name) VALUES (?, 'Колебания тест')`) + .run(subj.lastInsertRowid); + topicId = tp.lastInsertRowid; + }); + + it('GET /related requires auth (401)', async () => { + const res = await inject('GET', '/api/lab/sims/pendulum/related', null, null); + assert.equal(res.status, 401, `got ${res.status}`); + }); + + it('GET /related returns empty link buckets for a sim with no links', async () => { + const res = await inject('GET', '/api/lab/sims/pendulum/related', null, studentToken); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.sim.id, 'pendulum'); + assert.deepEqual(res.body.links.textbook, []); + assert.deepEqual(res.body.links.topic, []); + }); + + it('POST /links is admin-only (student → 403)', async () => { + const res = await inject('POST', '/api/lab/sims/pendulum/links', + { kind: 'textbook', ref_id: tbSlug }, studentToken); + assert.equal(res.status, 403, `got ${res.status}`); + }); + + it('admin can add a textbook link; label resolved from textbooks', async () => { + const res = await inject('POST', '/api/lab/sims/pendulum/links', + { kind: 'textbook', ref_id: tbSlug }, adminToken); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.link.kind, 'textbook'); + assert.equal(res.body.link.ref_id, tbSlug); + assert.equal(res.body.link.label, 'Физика тест', 'label resolved from textbook title'); + assert.ok(res.body.link.href.includes(tbSlug), 'href points to textbook'); + }); + + it('related now shows the textbook link', async () => { + const res = await inject('GET', '/api/lab/sims/pendulum/related', null, studentToken); + assert.equal(res.status, 200); + assert.equal(res.body.links.textbook.length, 1); + assert.equal(res.body.links.textbook[0].ref_id, tbSlug); + }); + + it('admin can add a topic link; label resolved from topics', async () => { + const res = await inject('POST', '/api/lab/sims/pendulum/links', + { kind: 'topic', ref_id: String(topicId) }, adminToken); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.link.label, 'Колебания тест'); + }); + + it('duplicate link → 409', async () => { + const res = await inject('POST', '/api/lab/sims/pendulum/links', + { kind: 'textbook', ref_id: tbSlug }, adminToken); + assert.equal(res.status, 409, `got ${res.status}`); + }); + + it('validation: bad kind → 400', async () => { + const res = await inject('POST', '/api/lab/sims/pendulum/links', + { kind: 'nope', ref_id: 'x' }, adminToken); + assert.equal(res.status, 400); + }); + + it('validation: missing ref_id → 400', async () => { + const res = await inject('POST', '/api/lab/sims/pendulum/links', + { kind: 'textbook' }, adminToken); + assert.equal(res.status, 400); + }); + + it('validation: unknown textbook → 404', async () => { + const res = await inject('POST', '/api/lab/sims/pendulum/links', + { kind: 'textbook', ref_id: 'ghost-book' }, adminToken); + assert.equal(res.status, 404); + }); + + it('validation: unknown topic → 404', async () => { + const res = await inject('POST', '/api/lab/sims/pendulum/links', + { kind: 'topic', ref_id: '999999' }, adminToken); + assert.equal(res.status, 404); + }); + + it('POST link to unknown sim → 404', async () => { + const res = await inject('POST', '/api/lab/sims/ghostsim/links', + { kind: 'textbook', ref_id: tbSlug }, adminToken); + assert.equal(res.status, 404); + }); + + it('reverse lookup: GET /links?kind=textbook&ref_id= returns linked enabled sims', async () => { + const res = await inject('GET', + `/api/lab/links?kind=textbook&ref_id=${encodeURIComponent(tbSlug)}`, null, studentToken); + assert.equal(res.status, 200, `got ${res.status}`); + assert.ok(res.body.sims.some(s => s.id === 'pendulum'), 'pendulum in reverse lookup'); + }); + + it('reverse lookup excludes disabled sims', async () => { + await inject('PATCH', '/api/lab/sims/pendulum', { enabled: false }, adminToken); + const res = await inject('GET', + `/api/lab/links?kind=textbook&ref_id=${encodeURIComponent(tbSlug)}`, null, studentToken); + assert.ok(!res.body.sims.some(s => s.id === 'pendulum'), 'disabled pendulum excluded'); + await inject('PATCH', '/api/lab/sims/pendulum', { enabled: true }, adminToken); // restore + }); + + it('reverse lookup: bad kind → 400', async () => { + const res = await inject('GET', '/api/lab/links?kind=nope&ref_id=x', null, studentToken); + assert.equal(res.status, 400); + }); + + it('admin can delete a link; related reflects removal', async () => { + // find the textbook link id + const rel = await inject('GET', '/api/lab/sims/pendulum/related', null, adminToken); + const linkId = rel.body.links.textbook[0].id; + const del = await inject('DELETE', `/api/lab/sims/pendulum/links/${linkId}`, null, adminToken); + assert.equal(del.status, 200, `got ${del.status}`); + const rel2 = await inject('GET', '/api/lab/sims/pendulum/related', null, adminToken); + assert.equal(rel2.body.links.textbook.length, 0, 'textbook link gone'); + }); + + it('delete unknown link → 404', async () => { + const res = await inject('DELETE', '/api/lab/sims/pendulum/links/999999', null, adminToken); + assert.equal(res.status, 404); + }); + + it('delete is admin-only (student → 403)', async () => { + const res = await inject('DELETE', '/api/lab/sims/pendulum/links/1', null, studentToken); + assert.equal(res.status, 403); + }); +});