feat(sim-builder): фаза 6 — раздача классу, клон, шаблоны, привязка к программе (custom_sims)

This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:06:30 +03:00
parent 1bee332ae1
commit cbb6edf372
10 changed files with 803 additions and 30 deletions
+183 -1
View File
@@ -11,6 +11,7 @@
* per-row ownership на каждой мутации, статусы 400/403/404.
*/
const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
/* ── Лимиты валидации спеки ──────────────────────────────────────────── */
const MAX_SPEC_BYTES = 200 * 1024; // 200 KB сериализованного JSON
@@ -333,7 +334,188 @@ function remove(req, res) {
return res.status(403).json({ error: 'forbidden' });
}
db.prepare('DELETE FROM custom_sims WHERE id = ?').run(req.params.id);
// Заодно чистим осиротевшие курикулумные связи (sim_id = 'custom:<id>').
try { db.prepare("DELETE FROM lab_sim_links WHERE sim_id = ?").run('custom:' + req.params.id); } catch (_e) { /* таблица может отсутствовать */ }
res.json({ ok: true });
}
module.exports = { list, get, create, update, remove, validateSpec };
/* ════════════════════════════════════════════════════════════════════════
Фаза 6 — раздача классу / клонирование / курикулумная привязка.
════════════════════════════════════════════════════════════════════════ */
/* Проверка владения симуляцией (владелец ИЛИ admin). Возвращает row или null. */
function ownedSim(req) {
const row = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id);
if (!row) return { row: null, code: 404 };
if (row.owner_id !== req.user.id && req.user.role !== 'admin') return { row: null, code: 403 };
return { row, code: 200 };
}
/* POST /api/custom-sims/:id/share body: { classId }
*
* РЕШЕНИЕ (копия vs доступ): published custom-sim И ТАК видна всем в каталоге
* /lab (list/get отдают published любому). Поэтому раздача классу — это НЕ копия
* (как у «Моих материалов», где копия нужна т.к. оригинал приватный), а:
* 1) авто-публикация (status -> published), чтобы ученики могли открыть;
* 2) адресное ДОЛГОВЕЧНОЕ уведомление ученикам класса со ссылкой
* /lab?sim=custom:<id> (notifications-таблица + SSE через pushNotif).
* Отдельная запись content_access не нужна: custom-sim не гейтится allowlist'ом
* 'sim' (тот гейтит только legacy lab_sims); published виден всем. */
function share(req, res) {
const { row, code } = ownedSim(req);
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
const b = req.body || {};
const classId = Number(b.classId);
if (!Number.isFinite(classId)) return res.status(400).json({ error: 'classId required' });
const cls = db.prepare('SELECT id, teacher_id, name FROM classes WHERE id = ?').get(classId);
if (!cls) return res.status(404).json({ error: 'class not found' });
if (cls.teacher_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'not your class' });
}
// Авто-публикация, чтобы ученики могли открыть симуляцию по ссылке.
if (row.status !== 'published') {
db.prepare("UPDATE custom_sims SET status = 'published', updated_at = datetime('now') WHERE id = ?").run(row.id);
}
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
const simTitle = row.title || 'симуляция';
const link = '/lab?sim=custom:' + row.id;
const recipients = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId).map(r => r.user_id);
let sent = 0;
for (const uid of recipients) {
if (!uid || uid === req.user.id) continue;
pushNotif(uid, 'sim_shared', `Новая симуляция от ${teacherName}: «${simTitle}»`, link);
sent++;
}
res.json({ ok: true, sent, status: 'published' });
}
/* POST /api/custom-sims/:id/clone — копия спеки текущему пользователю как draft.
* Источник: своя (любой статус) ИЛИ чужая published. Заголовок += « (копия)».
* Метаданные (subject/grade/cat) копируются; status всегда draft; version=1. */
function clone(req, res) {
const src = db.prepare('SELECT * FROM custom_sims WHERE id = ?').get(req.params.id);
if (!src) return res.status(404).json({ error: 'not found' });
// Клонировать можно свою (любую) или чужую только published.
if (src.owner_id !== req.user.id && src.status !== 'published' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'forbidden' });
}
const baseTitle = src.title || 'Симуляция';
const title = sanitizeText(baseTitle + ' (копия)');
const r = db.prepare(`
INSERT INTO custom_sims (owner_id, title, description, subject, grade, cat, spec_json, status, version)
VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', 1)
`).run(
req.user.id,
title,
src.description,
src.subject,
src.grade,
src.cat,
src.spec_json,
);
res.status(201).json({ id: Number(r.lastInsertRowid) });
}
/* ── Курикулумная привязка: переиспользуем lab_sim_links с sim_id='custom:<id>'.
sim_id в таблице — TEXT, поэтому отдельная таблица не нужна. Управляет
связями ВЛАДЕЛЕЦ симуляции (или admin), а не только admin как у lab_sims:
custom-sim принадлежит учителю. ───────────────────────────────────────── */
const LINK_KINDS = new Set(['textbook', 'topic', 'kmap', 'question']);
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;
}
if (!out.label) out.label = l.kind + ':' + l.ref_id;
return out;
}
/* GET /api/custom-sims/:id/related → { links:{ textbook:[], topic:[], kmap:[], question:[] } }
Доступно любому, кто может видеть симуляцию (own ИЛИ published). */
function related(req, res) {
const sim = db.prepare('SELECT id, owner_id, status FROM custom_sims WHERE id = ?').get(req.params.id);
if (!sim) return res.status(404).json({ error: 'not found' });
if (sim.owner_id !== req.user.id && sim.status !== 'published' && req.user.role !== 'admin') {
return res.status(403).json({ error: 'forbidden' });
}
const simId = 'custom:' + sim.id;
let rows;
try {
rows = 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 res.json({ links: {}, needs_migration: true });
}
const links = { textbook: [], topic: [], kmap: [], question: [] };
for (const l of rows) (links[l.kind] || (links[l.kind] = [])).push(decorateLink(l));
res.json({ links });
}
/* POST /api/custom-sims/:id/links body: { kind, ref_id, label? } — добавить связь.
Владелец/admin. */
function addLink(req, res) {
const { row, code } = ownedSim(req);
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
const b = req.body || {};
const kind = String(b.kind || '');
const refId = String(b.ref_id || '').trim();
if (!LINK_KINDS.has(kind)) return res.status(400).json({ error: 'неверный kind' });
if (!refId) return res.status(400).json({ error: 'ref_id обязателен' });
// Мягкая валидация существования цели (как в lab.js).
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;
const simId = 'custom:' + row.id;
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/custom-sims/:id/links/:linkId — удалить связь. Владелец/admin. */
function removeLink(req, res) {
const { row, code } = ownedSim(req);
if (!row) return res.status(code).json({ error: code === 404 ? 'not found' : 'forbidden' });
const linkId = Number(req.params.linkId);
if (!Number.isInteger(linkId)) return res.status(400).json({ error: 'неверный linkId' });
const simId = 'custom:' + row.id;
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 = {
list, get, create, update, remove, validateSpec,
share, clone, related, addLink, removeLink,
};