feat(lab-content-engine): phase 5 завершение — редактор связей в админке + кнопка в учебнике
- lab.js: GET /api/lab/links/all?kind= — пакетный обратный поиск (byRef map), чтобы каталог учебников не делал N+1 запросов - tests/lab-links.test.js: +3 теста для /links/all (group/400/401) -> 21/21 - admin/sections/sims.js: inline-редактор курикулумных связей на карточке симуляции (кнопка «Связи» -> панель: список связей с удалением + выбор учебника + добавить); использует /api/access/catalog, POST/DELETE /links. Без LS.modal (inline-панель) - textbooks.html: кнопка «В лабораторию» на карточке учебника, если есть связанные симуляции (один батч-запрос /links/all при загрузке); deep-link /lab?sim=<id> Двусторонняя навигация sim <-> учебник готова. Иконки .ic, без эмодзи. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -218,6 +218,35 @@ router.get('/links', (req, res) => {
|
||||
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? } */
|
||||
|
||||
@@ -148,6 +148,24 @@ describe('/api/lab curriculum links', () => {
|
||||
await inject('PATCH', '/api/lab/sims/pendulum', { enabled: true }, adminToken); // restore
|
||||
});
|
||||
|
||||
it('batch reverse lookup: GET /links/all?kind=textbook groups by ref_id', async () => {
|
||||
const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, studentToken);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.ok(res.body.byRef, 'byRef present');
|
||||
assert.ok(Array.isArray(res.body.byRef[tbSlug]), `byRef[${tbSlug}] is array`);
|
||||
assert.ok(res.body.byRef[tbSlug].some(s => s.id === 'pendulum'), 'pendulum grouped under tbSlug');
|
||||
});
|
||||
|
||||
it('batch reverse lookup: bad kind → 400', async () => {
|
||||
const res = await inject('GET', '/api/lab/links/all?kind=nope', null, studentToken);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('batch reverse lookup requires auth (401)', async () => {
|
||||
const res = await inject('GET', '/api/lab/links/all?kind=textbook', null, null);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user