feat(lab-content-engine): phase 5 - курикулумная привязка симуляций

- Миграция 043_lab_sim_links.sql: таблица связей (sim_id, kind[textbook|topic|
  kmap|question], ref_id, label), UNIQUE(sim_id,kind,ref_id) + индексы. Применена.
- lab.js (расширение):
  - GET /api/lab/sims/:id/related (auth inline) — связи по типам; label из
    textbooks/topics; href для навигации
  - GET /api/lab/links?kind=&ref_id= (auth) — обратный поиск включённых
    привязанных симуляций (для кнопки «Открыть в лаборатории»)
  - POST /api/lab/sims/:id/links (admin), DELETE .../links/:linkId (admin)
  - graceful-degradation если таблица ещё не отмигрирована
- tests/lab-links.test.js: 18 тестов (auth/роли/related/reverse/валидация/дубль/
  enabled-фильтр/удаление); seedRow() устойчив к NOT NULL дрейфу схемы
- plans: Фаза 5 done + handoff

Все мои тесты: lab-sims 11/11, lab-links 18/18. route-auth: новый :id-роут
защищён inline authMiddleware. Миграция применена к живой БД.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 16:27:05 +03:00
parent 72bd3ff72c
commit dead984d8a
3 changed files with 331 additions and 5 deletions
@@ -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);
+133 -5
View File
@@ -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;
+174
View File
@@ -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);
});
});