feat(sim-builder): фаза 6 — раздача классу, клон, шаблоны, привязка к программе (custom_sims)
This commit is contained in:
@@ -114,3 +114,13 @@ git push origin master
|
||||
- **Owner-only действия**: `owner_id === user.id` (user из `LS.initPage()`, поле `id` — канон всего фронта, ср. `t.createdBy === user.id` в theory.html). Edit → `location.href='/sim-builder?id='+dbid`; Delete → `LS.customSimDelete` + убрать карточку. Делегированный клик по контейнеру секции: `data-act` (edit/del, `stopPropagation`) vs `data-open` (открыть). Видимость draft/published обеспечивает сервер Ф3 (список = свои+чужие published).
|
||||
- **Embed/Ф7 заметка**: для `?sim=custom:*` открытие отложено до `LabCustom.init()` (и в обычном, и в embed-режиме). `_loadRelated('customsim_<id>')` дергает `/api/lab/sims/.../related` (404, тихо). LabRegistry не имеет unregister → удалённая custom остаётся заглушкой в реестре (карточки нет, ensureSpec вернёт 404). Источник spec для доски (Ф7): `LabCustom.ensureSpec(dbid)`.
|
||||
- **Смоук на РЕАЛЬНОМ registry/adapter**: harness грузит настоящие `_registry.js`+`_sim_adapter.js` в `vm`-контекст, стабит только SimEngine/LS/DOM, извлекает IIFE `LabCustom` из lab-glue по маркеру и прогоняет init→open→del. Гочи стаба: реальный код проверяет `window.LS` (api.js ставит и `window.LS`, и глобал `LS`) — в стабе надо ставить ОБА; `document.getElementById` стаба должен находить и динамически `appendChild`-нутые элементы (регистрировать по id в appendChild). 22/22.
|
||||
|
||||
### Phase 6 — Learnings
|
||||
|
||||
- **Раздача классу = доступ + уведомление, НЕ копия.** Ключевое отличие от «Моих материалов» (`shareMaterial`): там оригинал ПРИВАТНЫЙ, поэтому каждому ученику делается независимая КОПИЯ. У custom-sim published И ТАК видна всем в каталоге (`list`/`get` отдают published любому; custom-sim НЕ гейтится `content_access` allowlist'ом 'sim' — тот гейтит ТОЛЬКО legacy `lab_sims`). Поэтому share = (1) авто-публикация `status→published`, (2) адресное уведомление ученикам класса. Копия и запись content_access избыточны. Решение зафиксировано в CONTEXT.md.
|
||||
- **Долговечное уведомление: `pushNotif`, НЕ `sse.emit`.** materials.share шлёт `emit(uid, {...})` (только SSE, теряется если оффлайн) — там персистентность даёт сама копия. Для share без копии нужен durable канал: `require('../utils/notifications').pushNotif(uid, type, message, link)` — пишет в таблицу `notifications` И шлёт SSE. Ссылка `/lab?sim=custom:<id>` (Ф5 deep-link).
|
||||
- **`lab_sim_links.sim_id` — TEXT** (см. мигр.043), поэтому курикулумные связи custom переиспользуют ту же таблицу с `sim_id='custom:<id>'` — отдельная таблица не нужна. Связями СВОЕЙ симуляции рулит владелец/admin (а не только admin как у lab_sims в lab.js — custom-sim принадлежит учителю). DELETE симуляции должен чистить её связи вручную (у lab_sim_links нет FK на custom_sims). `/api/lab/links?kind=...&ref_id=` (обратный поиск) джойнит `lab_sims` — для custom не сработает (отдельный bulk-эндпоинт — остаток).
|
||||
- **Шаблоны = данные в JS, не код/файл.** `TEMPLATES` (массив спек v1) прямо в sim-builder.js; «Создать из шаблона» собирает синтетический sim-объект `{ id:null, status:'draft', spec, title, cat }` и зовёт существующий `loadFromSim` → simId сбрасывается в null + `history.replaceState('/sim-builder')`, чтобы первое «Сохранить» создало запись. `loadFromSim` уже корректно раскладывает plot-`range`→`range_a/range_b` (Ф4) — шаблоны с графиками round-trip без потерь.
|
||||
- **publish-toggle через PUT status.** Снять с публикации = `customSimUpdate(id, { status:'draft' })` (контроллер Ф3 уже принимает `status` в update). В билдере для уже сохранённой sim — `setStatus` (без полного save, не бампит version зря); в каталоге — кнопка publish/unpublish на owner-карточке.
|
||||
- **clone-источник:** своя любая ИЛИ чужая published (чужой draft → 403). Кнопка «Клонировать к себе» — только на чужой published-карточке и только для teacher/admin (`_isTeacherUser()`). Копируется `spec_json` как есть (уже санитизирован при сохранении оригинала), status=draft, version=1, title += ' (копия)'.
|
||||
- **Аддитивность сохранена**: lab-glue.js правлен только внутри IIFE `LabCustom` (ICON-блок + `_cardHtml` actions + делегат + 3 новые функции + экспорт); lab.html/classroom.html не тронуты. Кнопки — inline-стиль + SVG `.ic`, без эмодзи.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ router.use(authMiddleware);
|
||||
router.get('/', c.list);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id', c.get);
|
||||
// @public-by-design: router-level authMiddleware (above) + ownership/published check in handler
|
||||
router.get('/:id/related', c.related);
|
||||
|
||||
router.post('/', requireRole('teacher', 'admin'), c.create);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
@@ -20,4 +22,13 @@ router.put('/:id', requireRole('teacher', 'admin'), c.update);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id', requireRole('teacher', 'admin'), c.remove);
|
||||
|
||||
// Фаза 6 — раздача классу / клон / курикулумные связи. Мутации — inline
|
||||
// requireRole(teacher,admin) + per-row ownership в хендлере.
|
||||
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
|
||||
router.post('/:id/clone', requireRole('teacher', 'admin'), c.clone);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.post('/:id/links', requireRole('teacher', 'admin'), c.addLink);
|
||||
// @public-by-design: router-level authMiddleware (above) + per-row ownership check in handler
|
||||
router.delete('/:id/links/:linkId', requireRole('teacher', 'admin'), c.removeLink);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: /api/custom-sims — раздача / клон / курикулумная привязка (Фаза 6).
|
||||
* Covers:
|
||||
* share — ученик класса получает ДОЛГОВЕЧНОЕ уведомление, sim авто-публикуется;
|
||||
* раздача не своего класса / чужого draft → 403; неизвестный класс → 404.
|
||||
* clone — новый владелец, status=draft, spec скопирован, title += « (копия)»;
|
||||
* чужой published клонируется ОК; чужой draft → 403.
|
||||
* links — владелец привязывает учебник; чужой draft → 403; published related ОК.
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
// Mount /api/custom-sims on the shared test app (setup.js его не монтирует).
|
||||
app.use('/api/custom-sims', require('../src/routes/customSims'));
|
||||
|
||||
after(() => cleanup());
|
||||
|
||||
const VALID_SPEC = {
|
||||
specVersion: 1,
|
||||
meta: { title: 'Маятник' },
|
||||
viewport: { xmin: -5, xmax: 5, ymin: -5, ymax: 1 },
|
||||
params: [{ name: 'L', label: 'Длина', min: 0.5, max: 3, step: 0.1, value: 1.5 }],
|
||||
objects: [{ id: 'bob', type: 'circle', x: 'L*sin(t)', y: '-L*cos(t)', r: 0.2, color: '#9B5DE5' }],
|
||||
};
|
||||
|
||||
/* seedClass(teacherId, [studentIds]) → classId. Прямая вставка (seedRow-паттерн). */
|
||||
function seedClass(teacherId, studentIds) {
|
||||
const code = 'C' + Math.random().toString(36).slice(2, 10).toUpperCase();
|
||||
const r = db.prepare(
|
||||
'INSERT INTO classes (name, teacher_id, invite_code) VALUES (?, ?, ?)'
|
||||
).run('Класс ' + code, teacherId, code);
|
||||
const classId = Number(r.lastInsertRowid);
|
||||
const ins = db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)');
|
||||
for (const uid of studentIds) ins.run(classId, uid);
|
||||
return classId;
|
||||
}
|
||||
|
||||
async function createSim(token, overrides) {
|
||||
const res = await inject('POST', '/api/custom-sims',
|
||||
Object.assign({ title: 'Маятник', cat: 'phys', spec: VALID_SPEC }, overrides || {}), token);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('/api/custom-sims (Фаза 6: share / clone / links)', () => {
|
||||
let teacher, otherTeacher, student, studentB, admin;
|
||||
|
||||
before(async () => {
|
||||
teacher = await getToken('teacher');
|
||||
otherTeacher = await getToken('teacher');
|
||||
student = await getToken('student');
|
||||
studentB = await getToken('student');
|
||||
admin = await getToken('admin');
|
||||
});
|
||||
|
||||
/* ── SHARE ──────────────────────────────────────────────────────────── */
|
||||
describe('share', () => {
|
||||
it('teacher shares a DRAFT sim to own class → 200, auto-publish, students notified', async () => {
|
||||
const classId = seedClass(teacher.userId, [student.userId, studentB.userId]);
|
||||
const c = await createSim(teacher.token); // draft by default
|
||||
assert.equal(c.status, 201);
|
||||
const simId = c.body.id;
|
||||
|
||||
const before = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
|
||||
assert.equal(before.status, 'draft', 'created as draft');
|
||||
|
||||
const res = await inject('POST', `/api/custom-sims/${simId}/share`, { classId }, teacher.token);
|
||||
assert.equal(res.status, 200, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
assert.equal(res.body.sent, 2, 'two students notified');
|
||||
assert.equal(res.body.status, 'published', 'reports published');
|
||||
|
||||
// Авто-публикация в БД.
|
||||
const after = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(simId);
|
||||
assert.equal(after.status, 'published', 'sim auto-published');
|
||||
|
||||
// Долговечное уведомление со ссылкой /lab?sim=custom:<id>.
|
||||
const notif = db.prepare(
|
||||
"SELECT type, link FROM notifications WHERE user_id = ? AND type = 'sim_shared' ORDER BY id DESC"
|
||||
).get(student.userId);
|
||||
assert.ok(notif, 'student has a sim_shared notification');
|
||||
assert.equal(notif.link, '/lab?sim=custom:' + simId, 'notification links to the sim');
|
||||
});
|
||||
|
||||
it('share requires classId (400)', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, {}, teacher.token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('share to unknown class → 404', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId: 99999 }, teacher.token);
|
||||
assert.equal(res.status, 404, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it("share to a class that isn't yours → 403", async () => {
|
||||
const classId = seedClass(otherTeacher.userId, [student.userId]);
|
||||
const c = await createSim(teacher.token); // teacher owns the sim
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, teacher.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it("non-owner cannot share someone else's DRAFT (403)", async () => {
|
||||
const classId = seedClass(otherTeacher.userId, [student.userId]);
|
||||
const c = await createSim(teacher.token); // owned by teacher, draft
|
||||
// otherTeacher tries to share teacher's draft to otherTeacher's own class.
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, otherTeacher.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('student cannot share (role-gated 403)', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const classId = seedClass(teacher.userId, [student.userId]);
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/share`, { classId }, student.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── CLONE ──────────────────────────────────────────────────────────── */
|
||||
describe('clone', () => {
|
||||
it('owner clones own sim → new draft owned by caller, spec copied, title += копия', async () => {
|
||||
const c = await createSim(teacher.token, { title: 'Оригинал', subject: 'physics', grade: 9 });
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, teacher.token);
|
||||
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
const newId = res.body.id;
|
||||
assert.notEqual(newId, c.body.id, 'new row');
|
||||
|
||||
const get = await inject('GET', `/api/custom-sims/${newId}`, null, teacher.token);
|
||||
const s = get.body.sim;
|
||||
assert.equal(s.owner_id, teacher.userId, 'caller owns the clone');
|
||||
assert.equal(s.status, 'draft', 'clone is draft');
|
||||
assert.equal(s.version, 1, 'clone version reset to 1');
|
||||
assert.equal(s.title, 'Оригинал (копия)', 'title gets (копия)');
|
||||
assert.equal(s.subject, 'physics');
|
||||
assert.equal(s.grade, 9);
|
||||
assert.equal(s.spec.objects.length, VALID_SPEC.objects.length, 'spec copied');
|
||||
assert.equal(s.spec.objects[0].id, 'bob', 'spec content copied');
|
||||
});
|
||||
|
||||
it('teacher clones ANOTHER teacher PUBLISHED sim → 201 (now owned by cloner, draft)', async () => {
|
||||
// teacher creates + publishes.
|
||||
const c = await createSim(teacher.token, { status: 'published' });
|
||||
assert.equal(c.status, 201);
|
||||
const src = db.prepare('SELECT status FROM custom_sims WHERE id = ?').get(c.body.id);
|
||||
assert.equal(src.status, 'published');
|
||||
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, otherTeacher.token);
|
||||
assert.equal(res.status, 201, `got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||
const get = await inject('GET', `/api/custom-sims/${res.body.id}`, null, otherTeacher.token);
|
||||
assert.equal(get.body.sim.owner_id, otherTeacher.userId);
|
||||
assert.equal(get.body.sim.status, 'draft');
|
||||
});
|
||||
|
||||
it("teacher CANNOT clone another teacher's DRAFT (403)", async () => {
|
||||
const c = await createSim(teacher.token); // draft
|
||||
const res = await inject('POST', `/api/custom-sims/${c.body.id}/clone`, null, otherTeacher.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('clone of unknown id → 404', async () => {
|
||||
const res = await inject('POST', '/api/custom-sims/99999/clone', null, teacher.token);
|
||||
assert.equal(res.status, 404, `got ${res.status}`);
|
||||
});
|
||||
});
|
||||
|
||||
/* ── CURRICULUM LINKS (lab_sim_links, sim_id='custom:<id>') ──────────── */
|
||||
describe('links', () => {
|
||||
let bookSlug;
|
||||
before(() => {
|
||||
// Засеять учебник для привязки (textbooks.slug — ref_id для kind=textbook).
|
||||
bookSlug = 'phys-test-' + Math.random().toString(36).slice(2, 7);
|
||||
db.prepare(
|
||||
'INSERT INTO textbooks (slug, title, subject, grade, html_path, is_active) VALUES (?, ?, ?, ?, ?, 1)'
|
||||
).run(bookSlug, 'Физика тест', 'physics', 9, bookSlug + '.html');
|
||||
});
|
||||
|
||||
it('owner links own sim to a textbook, related lists it', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||
{ kind: 'textbook', ref_id: bookSlug }, teacher.token);
|
||||
assert.equal(add.status, 200, `got ${add.status}: ${JSON.stringify(add.body)}`);
|
||||
assert.equal(add.body.link.kind, 'textbook');
|
||||
|
||||
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, teacher.token);
|
||||
assert.equal(rel.status, 200);
|
||||
assert.equal(rel.body.links.textbook.length, 1, 'one textbook link');
|
||||
assert.equal(rel.body.links.textbook[0].ref_id, bookSlug);
|
||||
|
||||
// Удаление связи.
|
||||
const linkId = add.body.link.id;
|
||||
const del = await inject('DELETE', `/api/custom-sims/${c.body.id}/links/${linkId}`, null, teacher.token);
|
||||
assert.equal(del.status, 200);
|
||||
const rel2 = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, teacher.token);
|
||||
assert.equal(rel2.body.links.textbook.length, 0, 'link removed');
|
||||
});
|
||||
|
||||
it('linking to unknown textbook → 404', async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||
{ kind: 'textbook', ref_id: 'no-such-book' }, teacher.token);
|
||||
assert.equal(add.status, 404, `got ${add.status}`);
|
||||
});
|
||||
|
||||
it("non-owner CANNOT add link to someone else's draft (403)", async () => {
|
||||
const c = await createSim(teacher.token);
|
||||
const add = await inject('POST', `/api/custom-sims/${c.body.id}/links`,
|
||||
{ kind: 'textbook', ref_id: bookSlug }, otherTeacher.token);
|
||||
assert.equal(add.status, 403, `got ${add.status}`);
|
||||
});
|
||||
|
||||
it('related on a published sim is readable by any user (student)', async () => {
|
||||
const c = await createSim(teacher.token, { status: 'published' });
|
||||
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, student.token);
|
||||
assert.equal(rel.status, 200, `got ${rel.status}`);
|
||||
assert.ok(rel.body.links, 'links object present');
|
||||
});
|
||||
|
||||
it("related on someone else's draft → 403 for non-owner", async () => {
|
||||
const c = await createSim(teacher.token); // draft
|
||||
const rel = await inject('GET', `/api/custom-sims/${c.body.id}/related`, null, otherTeacher.token);
|
||||
assert.equal(rel.status, 403, `got ${rel.status}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -715,6 +715,21 @@ const SIMS = [
|
||||
|
||||
var _EDIT_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></svg>';
|
||||
var _DEL_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||||
var _SHARE_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||
var _CLONE_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
var _PUB_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
|
||||
var _UNPUB_ICON = '<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px"><path d="M2 2l20 20"/><path d="M12 2a15.3 15.3 0 0 1 4 10c0 1.3-.2 2.6-.5 3.8M6.5 6.5A15.3 15.3 0 0 0 12 22a15.3 15.3 0 0 0 3.3-5"/><path d="M2 12h7m6 0h7"/></svg>';
|
||||
|
||||
function _isTeacherUser() {
|
||||
try { return typeof user !== 'undefined' && user && (user.role === 'teacher' || user.role === 'admin'); }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
function _btn(act, id, html, extra, title) {
|
||||
return '<button type="button" data-act="' + act + '" data-id="' + _esc(id) + '" ' +
|
||||
(title ? 'title="' + _esc(title) + '" ' : '') +
|
||||
'style="display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:.78rem;font-weight:700;padding:7px 10px;border-radius:10px;cursor:pointer;' + (extra || '') + '">' +
|
||||
html + '</button>';
|
||||
}
|
||||
|
||||
function _cardHtml(m) {
|
||||
var owner = _isOwner(m);
|
||||
@@ -726,14 +741,26 @@ const SIMS = [
|
||||
else if (owner) badges += '<span style="display:inline-flex;align-items:center;gap:4px;font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:99px;background:rgba(255,255,255,.06);color:var(--text-3);border:1px solid rgba(255,255,255,.14)">Черновик</span>';
|
||||
var actions = '';
|
||||
if (owner) {
|
||||
var STYLE_PRI = 'flex:1;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)';
|
||||
var STYLE_GHOST = 'background:rgba(255,255,255,.05);color:var(--text-2);border:1px solid rgba(255,255,255,.16)';
|
||||
var STYLE_DEL = 'background:rgba(244,91,105,.1);color:#f45b69;border:1px solid rgba(244,91,105,.28)';
|
||||
var pubBtn = published
|
||||
? _btn('unpublish', m.id, _UNPUB_ICON, STYLE_GHOST, 'Снять с публикации')
|
||||
: _btn('publish', m.id, _PUB_ICON, STYLE_GHOST, 'Опубликовать');
|
||||
actions =
|
||||
'<div style="display:flex;gap:8px;margin-top:12px">' +
|
||||
'<button type="button" data-act="edit" data-id="' + _esc(m.id) + '" ' +
|
||||
'style="flex:1;display:inline-flex;align-items:center;justify-content:center;gap:6px;font-size:.78rem;font-weight:700;padding:7px 10px;border-radius:10px;cursor:pointer;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)">' +
|
||||
_EDIT_ICON + 'Редактировать</button>' +
|
||||
'<button type="button" data-act="del" data-id="' + _esc(m.id) + '" ' +
|
||||
'style="display:inline-flex;align-items:center;justify-content:center;padding:7px 11px;border-radius:10px;cursor:pointer;background:rgba(244,91,105,.1);color:#f45b69;border:1px solid rgba(244,91,105,.28)" title="Удалить">' +
|
||||
_DEL_ICON + '</button>' +
|
||||
_btn('edit', m.id, _EDIT_ICON + 'Редактировать', STYLE_PRI) +
|
||||
_btn('del', m.id, _DEL_ICON, STYLE_DEL, 'Удалить') +
|
||||
'</div>' +
|
||||
'<div style="display:flex;gap:8px;margin-top:8px">' +
|
||||
_btn('share', m.id, _SHARE_ICON + 'Раздать классу', STYLE_GHOST + ';flex:1') +
|
||||
pubBtn +
|
||||
'</div>';
|
||||
} else if (published && _isTeacherUser()) {
|
||||
actions =
|
||||
'<div style="display:flex;gap:8px;margin-top:12px">' +
|
||||
_btn('clone', m.id, _CLONE_ICON + 'Клонировать к себе',
|
||||
'flex:1;background:rgba(155,93,229,.12);color:var(--violet);border:1px solid rgba(155,93,229,.3)') +
|
||||
'</div>';
|
||||
}
|
||||
var preview = '<svg class="sim-preview" viewBox="0 0 300 140" preserveAspectRatio="xMidYMid slice" xmlns="http://www.w3.org/2000/svg">' +
|
||||
@@ -792,6 +819,14 @@ const SIMS = [
|
||||
location.href = '/sim-builder?id=' + encodeURIComponent(id);
|
||||
} else if (act === 'del') {
|
||||
del(id);
|
||||
} else if (act === 'share') {
|
||||
shareToClass(id);
|
||||
} else if (act === 'clone') {
|
||||
clone(id);
|
||||
} else if (act === 'publish') {
|
||||
setStatus(id, 'published');
|
||||
} else if (act === 'unpublish') {
|
||||
setStatus(id, 'draft');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -818,6 +853,69 @@ const SIMS = [
|
||||
});
|
||||
}
|
||||
|
||||
// Опубликовать / снять с публикации (владельцу). PUT status.
|
||||
function setStatus(dbid, status) {
|
||||
if (!window.LS || !LS.customSimUpdate) return;
|
||||
LS.customSimUpdate(dbid, { status: status }).then(function () {
|
||||
if (_meta[dbid]) _meta[dbid].status = status;
|
||||
renderSection(_catFilter);
|
||||
if (LS.toast) LS.toast(status === 'published' ? 'Опубликовано' : 'Снято с публикации', 'success');
|
||||
}).catch(function (e) {
|
||||
if (LS.toast) LS.toast((e && e.message) || 'Не удалось изменить статус', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Клонировать чужую (published) симуляцию к себе как черновик и открыть в билдере.
|
||||
function clone(dbid) {
|
||||
if (!window.LS || !LS.customSimClone) return;
|
||||
LS.customSimClone(dbid).then(function (res) {
|
||||
var newId = res && res.id;
|
||||
if (newId) {
|
||||
if (LS.toast) LS.toast('Скопировано в ваши черновики', 'success');
|
||||
location.href = '/sim-builder?id=' + encodeURIComponent(newId);
|
||||
}
|
||||
}).catch(function (e) {
|
||||
if (LS.toast) LS.toast((e && e.message) || 'Не удалось клонировать', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Раздать классу: модалка выбора класса -> LS.customSimShare (авто-публикует
|
||||
// и шлёт уведомление ученикам со ссылкой /lab?sim=custom:<id>).
|
||||
function shareToClass(dbid) {
|
||||
if (!window.LS || !LS.customSimShare || !LS.getClasses || !LS.modal) return;
|
||||
LS.getClasses().then(function (classes) {
|
||||
if (!Array.isArray(classes) || !classes.length) {
|
||||
if (LS.toast) LS.toast('Нет классов для раздачи', 'warn');
|
||||
return;
|
||||
}
|
||||
var opts = classes.map(function (c) {
|
||||
return '<option value="' + _esc(c.id) + '">' + _esc(c.name) + '</option>';
|
||||
}).join('');
|
||||
var content = '<div style="display:flex;flex-direction:column;gap:8px">' +
|
||||
'<label style="font-size:.8rem;color:var(--text-3)">Класс</label>' +
|
||||
'<select id="cs-share-class" style="width:100%;box-sizing:border-box;padding:9px 11px;border:1px solid var(--border);border-radius:9px;font:inherit;background:var(--surface);color:var(--text)">' + opts + '</select>' +
|
||||
'<div style="font-size:.78rem;color:var(--text-3)">Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.</div>' +
|
||||
'</div>';
|
||||
var m = LS.modal({ title: 'Раздать классу', content: content, size: 'sm', actions: [
|
||||
{ label: 'Отмена', onClick: function () { m.close(); } },
|
||||
{ label: 'Раздать', primary: true, onClick: function () {
|
||||
var sel = m.body.querySelector('#cs-share-class');
|
||||
var classId = sel ? Number(sel.value) : NaN;
|
||||
LS.customSimShare(dbid, { classId: classId }).then(function (r) {
|
||||
m.close();
|
||||
if (_meta[dbid]) _meta[dbid].status = 'published';
|
||||
renderSection(_catFilter);
|
||||
if (LS.toast) LS.toast('Отправлено ученикам: ' + ((r && r.sent) || 0), 'success');
|
||||
}).catch(function (e) {
|
||||
if (LS.toast) LS.toast((e && e.message) || 'Ошибка раздачи', 'error');
|
||||
});
|
||||
} }
|
||||
] });
|
||||
}).catch(function () {
|
||||
if (LS.toast) LS.toast('Не удалось загрузить классы', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
// Загрузить список custom-sims, зарегистрировать ленивые манифесты, нарисовать секцию.
|
||||
function init() {
|
||||
if (_initPromise) return _initPromise;
|
||||
@@ -844,6 +942,9 @@ const SIMS = [
|
||||
resolveId: resolveId,
|
||||
renderSection: renderSection,
|
||||
ensureSpec: ensureSpec,
|
||||
del: del
|
||||
del: del,
|
||||
share: shareToClass,
|
||||
clone: clone,
|
||||
setStatus: setStatus
|
||||
};
|
||||
})();
|
||||
|
||||
+169
-2
@@ -320,16 +320,26 @@
|
||||
var statusBadge = this.status === 'published'
|
||||
? '<span class="sbu-badge sbu-badge-pub">Опубликовано</span>'
|
||||
: '<span class="sbu-badge">Черновик</span>';
|
||||
// Кнопка публикации: для опубликованной — «Снять с публикации»; иначе «Опубликовать».
|
||||
var pubBtn = this.status === 'published'
|
||||
? '<button class="btn-ghost sbu-tb-btn" data-a="unpublish" title="Вернуть в черновик">' + ICON.unpublish + ' Снять</button>'
|
||||
: '<button class="btn-primary sbu-tb-btn" data-a="publish">' + ICON.send + ' Опубликовать</button>';
|
||||
// «Раздать классу» доступна только для уже сохранённой симуляции.
|
||||
var shareBtn = this.simId
|
||||
? '<button class="btn-ghost sbu-tb-btn" data-a="share" title="Раздать классу">' + ICON.send + ' Раздать</button>'
|
||||
: '';
|
||||
t.innerHTML =
|
||||
'<div class="sbu-tb-left">' +
|
||||
'<span class="sbu-tb-title">' + (this.simId ? 'Редактор симуляции' : 'Новая симуляция') + '</span>' +
|
||||
statusBadge +
|
||||
'</div>' +
|
||||
'<div class="sbu-tb-right">' +
|
||||
'<button class="btn-ghost sbu-tb-btn" data-a="template" title="Создать из шаблона">' + ICON.template + ' Шаблон</button>' +
|
||||
'<button class="btn-ghost sbu-tb-btn" data-a="test" title="Запустить превью">' + ICON.play + ' Тест</button>' +
|
||||
'<button class="btn-ghost sbu-tb-btn" data-a="reset" title="Сброс превью">' + ICON.reset + ' Сброс</button>' +
|
||||
'<button class="btn-ghost sbu-tb-btn" data-a="save">' + ICON.save + ' Сохранить</button>' +
|
||||
'<button class="btn-primary sbu-tb-btn" data-a="publish">' + ICON.send + ' Опубликовать</button>' +
|
||||
shareBtn +
|
||||
pubBtn +
|
||||
'</div>';
|
||||
t.querySelectorAll('[data-a]').forEach(function (b) {
|
||||
b.addEventListener('click', function () { self.onToolbar(b.getAttribute('data-a')); });
|
||||
@@ -341,6 +351,96 @@
|
||||
if (action === 'reset') { if (this.inst && this.inst.reset) this.inst.reset(); return; }
|
||||
if (action === 'save') { this.save(false); return; }
|
||||
if (action === 'publish') { this.save(true); return; }
|
||||
if (action === 'unpublish') { this.setStatus('draft'); return; }
|
||||
if (action === 'share') { this.openShareModal(); return; }
|
||||
if (action === 'template') { this.openTemplateModal(); return; }
|
||||
};
|
||||
|
||||
/* Изменить статус публикации уже сохранённой симуляции (PUT status). */
|
||||
Builder.prototype.setStatus = function (status) {
|
||||
var self = this;
|
||||
if (!this.simId) { this.save(status === 'published'); return; }
|
||||
global.LS.customSimUpdate(this.simId, { status: status }).then(function () {
|
||||
self.status = status;
|
||||
self.renderToolbar();
|
||||
global.LS.toast(status === 'published' ? 'Опубликовано' : 'Снято с публикации', 'success');
|
||||
}).catch(function (e) {
|
||||
global.LS.toast((e && e.message) || 'Ошибка', 'error');
|
||||
});
|
||||
};
|
||||
|
||||
/* Раздать классу: модалка выбора класса -> LS.customSimShare (авто-публикует +
|
||||
уведомляет учеников со ссылкой /lab?sim=custom:<id>). */
|
||||
Builder.prototype.openShareModal = function () {
|
||||
var self = this;
|
||||
if (!this.simId) { global.LS.toast('Сначала сохраните симуляцию', 'warn'); return; }
|
||||
global.LS.getClasses().then(function (classes) {
|
||||
if (!Array.isArray(classes) || !classes.length) { global.LS.toast('Нет классов для раздачи', 'warn'); return; }
|
||||
var opts = classes.map(function (c) {
|
||||
return '<option value="' + esc(c.id) + '">' + esc(c.name) + '</option>';
|
||||
}).join('');
|
||||
var content = '<div style="display:flex;flex-direction:column;gap:8px">' +
|
||||
'<label style="font-size:.8rem;color:var(--text-3)">Класс</label>' +
|
||||
'<select id="sbu-share-class" class="sbu-in">' + opts + '</select>' +
|
||||
'<div style="font-size:.78rem;color:var(--text-3)">Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.</div>' +
|
||||
'</div>';
|
||||
var m = global.LS.modal({ title: 'Раздать классу', content: content, size: 'sm', actions: [
|
||||
{ label: 'Отмена', onClick: function () { m.close(); } },
|
||||
{ label: 'Раздать', primary: true, onClick: function () {
|
||||
var sel = m.body.querySelector('#sbu-share-class');
|
||||
var classId = sel ? Number(sel.value) : NaN;
|
||||
global.LS.customSimShare(self.simId, { classId: classId }).then(function (r) {
|
||||
m.close();
|
||||
self.status = 'published';
|
||||
self.renderToolbar();
|
||||
global.LS.toast('Отправлено ученикам: ' + ((r && r.sent) || 0), 'success');
|
||||
}).catch(function (e) {
|
||||
global.LS.toast((e && e.message) || 'Ошибка раздачи', 'error');
|
||||
});
|
||||
} }
|
||||
] });
|
||||
}).catch(function () { global.LS.toast('Не удалось загрузить классы', 'error'); });
|
||||
};
|
||||
|
||||
/* Старт из шаблона: выбор готовой спеки -> загрузка в редактор как НОВАЯ
|
||||
симуляция (simId сбрасывается, чтобы первое «Сохранить» создало запись). */
|
||||
Builder.prototype.openTemplateModal = function () {
|
||||
var self = this;
|
||||
var cards = TEMPLATES.map(function (tpl, i) {
|
||||
return '<button type="button" data-tpl="' + i + '" style="text-align:left;display:flex;flex-direction:column;gap:4px;padding:11px 13px;border:1px solid var(--border);border-radius:10px;background:#fff;cursor:pointer">' +
|
||||
'<span style="font-weight:800;font-size:.84rem;color:var(--text)">' + esc(tpl.name) + '</span>' +
|
||||
'<span style="font-size:.74rem;color:var(--text-3)">' + esc(tpl.desc) + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
var content = '<div style="display:flex;flex-direction:column;gap:8px">' + cards +
|
||||
'<div style="font-size:.74rem;color:var(--text-3);margin-top:4px">Шаблон заменит текущую сцену и создаст новую симуляцию.</div></div>';
|
||||
var m = global.LS.modal({ title: 'Создать из шаблона', content: content, size: 'sm', actions: [
|
||||
{ label: 'Закрыть', onClick: function () { m.close(); } }
|
||||
] });
|
||||
m.body.querySelectorAll('[data-tpl]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var tpl = TEMPLATES[Number(b.getAttribute('data-tpl'))];
|
||||
if (!tpl) return;
|
||||
var apply = function () {
|
||||
self.simId = null;
|
||||
try { global.history.replaceState({}, '', '/sim-builder'); } catch (e) {}
|
||||
// loadFromSim ждёт sim-объект; собираем синтетический из спеки шаблона.
|
||||
var spec = JSON.parse(JSON.stringify(tpl.spec));
|
||||
self.loadFromSim({
|
||||
id: null, status: 'draft', version: 1,
|
||||
title: (spec.meta && spec.meta.title) || tpl.name,
|
||||
description: (spec.meta && spec.meta.desc) || '',
|
||||
subject: spec.subject || '', grade: spec.grade != null ? spec.grade : '',
|
||||
cat: tpl.cat || spec.cat || '', spec: spec
|
||||
});
|
||||
m.close();
|
||||
global.LS.toast('Шаблон загружен', 'success');
|
||||
};
|
||||
var hasContent = self.st.params.length || self.st.objects.length || self.st.plots.length;
|
||||
if (hasContent && !global.confirm('Заменить текущую сцену шаблоном «' + tpl.name + '»?')) return;
|
||||
apply();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/* ════════════════════════ ВАЛИДАЦИЯ (клиент, до запроса) ════════════════════════ */
|
||||
@@ -1100,9 +1200,76 @@
|
||||
trash: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
|
||||
chev: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="6 9 12 15 18 9"/></svg>',
|
||||
target: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5"/><line x1="12" y1="1" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="1" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="23" y2="12"/></svg>',
|
||||
cog: '<svg viewBox="0 0 24 24" width="13" height="13" style="vertical-align:-2px" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>'
|
||||
cog: '<svg viewBox="0 0 24 24" width="13" height="13" style="vertical-align:-2px" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
|
||||
template: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>',
|
||||
unpublish: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.1"><path d="M3 3l18 18"/><path d="M10.5 5.1A15.3 15.3 0 0 1 12 5a15.3 15.3 0 0 1 4 7M6.3 6.3A15.3 15.3 0 0 0 12 19a15.3 15.3 0 0 0 3-4"/><path d="M3 12h7m5 0h6"/></svg>'
|
||||
};
|
||||
|
||||
/* ── Встроенные шаблоны стартовых спек (Фаза 6) ──────────────────────────
|
||||
Данные, не код. Каждый — полноценная валидная спека v1: «Создать из шаблона»
|
||||
загружает её через loadFromSim как новую симуляцию. */
|
||||
var TEMPLATES = [
|
||||
{
|
||||
name: 'Пустая сцена', cat: 'phys',
|
||||
desc: 'Чистый холст с осями и сеткой — начать с нуля.',
|
||||
spec: {
|
||||
specVersion: 1, meta: { title: 'Новая симуляция', desc: '' },
|
||||
viewport: { xmin: -1, xmax: 10, ymin: -1, ymax: 10, grid: true, axes: true },
|
||||
time: { autoplay: false, loop: true, speed: 1 }, params: [], objects: []
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Математический маятник', cat: 'phys',
|
||||
desc: 'Груз на нити: угол колеблется по гармоническому закону.',
|
||||
spec: {
|
||||
specVersion: 1, meta: { title: 'Маятник', desc: 'Колебания груза на нити' },
|
||||
viewport: { xmin: -3, xmax: 3, ymin: -3.4, ymax: 0.6, grid: true, axes: true },
|
||||
time: { autoplay: true, loop: true, speed: 1 },
|
||||
params: [
|
||||
{ name: 'L', label: 'Длина нити', min: 0.5, max: 3, step: 0.1, value: 2.4, unit: 'м' },
|
||||
{ name: 'A', label: 'Амплитуда', min: 0.1, max: 1, step: 0.05, value: 0.5, unit: 'рад' }
|
||||
],
|
||||
objects: [
|
||||
{ type: 'segment', x1: 0, y1: 0, x2: 'L*sin(A*cos(2.2*t))', y2: '-L*cos(A*cos(2.2*t))', color: '#94a3b8', width: 2 },
|
||||
{ id: 'bob', type: 'circle', x: 'L*sin(A*cos(2.2*t))', y: '-L*cos(A*cos(2.2*t))', r: 0.18, color: '#9B5DE5' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'График y = f(x)', cat: 'math',
|
||||
desc: 'Параметрический график функции с настраиваемыми коэффициентами.',
|
||||
spec: {
|
||||
specVersion: 1, meta: { title: 'График функции', desc: 'y = a*sin(b*x)' },
|
||||
viewport: { xmin: -6.5, xmax: 6.5, ymin: -3.5, ymax: 3.5, grid: true, axes: true },
|
||||
time: { autoplay: false, loop: true, speed: 1 },
|
||||
params: [
|
||||
{ name: 'a', label: 'Амплитуда a', min: -3, max: 3, step: 0.1, value: 2 },
|
||||
{ name: 'b', label: 'Частота b', min: 0.2, max: 4, step: 0.1, value: 1 }
|
||||
],
|
||||
objects: [
|
||||
{ type: 'plot', expr: 'a*sin(b*x)', var: 'x', range: [-6.5, 6.5], samples: 200, color: '#06D6E0', width: 2 }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Бросок тела', cat: 'phys',
|
||||
desc: 'Траектория тела под углом к горизонту (кинематика).',
|
||||
spec: {
|
||||
specVersion: 1, meta: { title: 'Бросок тела', desc: 'Движение в поле тяжести' },
|
||||
viewport: { xmin: -1, xmax: 22, ymin: -1, ymax: 12, grid: true, axes: true },
|
||||
time: { autoplay: true, loop: true, speed: 1 },
|
||||
params: [
|
||||
{ name: 'v', label: 'Скорость', min: 5, max: 25, step: 0.5, value: 16, unit: 'м/с' },
|
||||
{ name: 'ang', label: 'Угол', min: 10, max: 80, step: 1, value: 45, unit: 'град' }
|
||||
],
|
||||
objects: [
|
||||
{ id: 'b', type: 'circle', x: 'v*cos(ang*pi/180)*t', y: 'v*sin(ang*pi/180)*t - 4.9*t*t', r: 0.25, color: '#9B5DE5' },
|
||||
{ type: 'plot', expr: '(x*tan(ang*pi/180)) - (4.9*x*x)/((v*cos(ang*pi/180))^2)', var: 'x', range: [0, 22], samples: 150, color: 'rgba(6,214,224,0.5)', width: 1.5 }
|
||||
]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/* plot-объект сериализуется особым путём (range_a/range_b -> range, без UI-полей);
|
||||
все прочие типы — через _stripObjOrig (удаление _uid и пустых полей). */
|
||||
var _stripObjOrig = stripObj;
|
||||
|
||||
@@ -1040,6 +1040,7 @@ window.LS = {
|
||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||
customSimsList, customSimGet, customSimCreate, customSimUpdate, customSimDelete,
|
||||
customSimShare, customSimClone, customSimRelated, customSimAddLink, customSimDelLink,
|
||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
|
||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||
@@ -1265,6 +1266,11 @@ async function customSimGet(id) { return req('GET', `/custom-sims/${i
|
||||
async function customSimCreate(data) { return req('POST', '/custom-sims', data); }
|
||||
async function customSimUpdate(id, d) { return req('PUT', `/custom-sims/${id}`, d); }
|
||||
async function customSimDelete(id) { return req('DELETE', `/custom-sims/${id}`); }
|
||||
async function customSimShare(id, d) { return req('POST', `/custom-sims/${id}/share`, d); }
|
||||
async function customSimClone(id) { return req('POST', `/custom-sims/${id}/clone`); }
|
||||
async function customSimRelated(id) { return req('GET', `/custom-sims/${id}/related`); }
|
||||
async function customSimAddLink(id, d) { return req('POST', `/custom-sims/${id}/links`, d); }
|
||||
async function customSimDelLink(id, lid){ return req('DELETE', `/custom-sims/${id}/links/${lid}`); }
|
||||
async function assistantContext() { return req('GET', '/assistant/context'); }
|
||||
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
# Feature Context: Конструктор симуляций (SimForge)
|
||||
|
||||
## Current State
|
||||
- **Фаза 6 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Файлы:
|
||||
`backend/src/controllers/customSimController.js` (+share/clone/related/addLink/removeLink, импорт
|
||||
`pushNotif`), `backend/src/routes/customSims.js` (+POST `/:id/share`, POST `/:id/clone`, GET
|
||||
`/:id/related`, POST `/:id/links`, DELETE `/:id/links/:linkId`), `js/api.js` (+`customSimShare/
|
||||
Clone/Related/AddLink/DelLink`), `frontend/js/labs/lab-glue.js` (аддитивно в IIFE LabCustom:
|
||||
кнопки share/clone/publish-toggle на карточках + делегат + `shareToClass/clone/setStatus`, ICON-блок),
|
||||
`frontend/js/sim-builder.js` (тулбар: «Шаблон»/«Раздать»/publish-toggle; методы `setStatus/
|
||||
openShareModal/openTemplateModal`; данные `TEMPLATES`×4; ICON.template/unpublish),
|
||||
`backend/tests/custom-sims-share.test.js` (new, 15 it, все зелёные).
|
||||
- **РЕШЕНИЕ копия-vs-доступ (зафиксировано):** published custom-sim видна ВСЕМ в каталоге /lab
|
||||
(`list`/`get` отдают published любому; custom-sim НЕ гейтится allowlist'ом content_access 'sim' —
|
||||
тот гейтит только legacy `lab_sims`). Поэтому «раздать классу» = (1) авто-публикация
|
||||
(status→published), (2) ДОЛГОВЕЧНОЕ адресное уведомление ученикам класса через `pushNotif`
|
||||
(notifications-таблица + SSE) со ссылкой `/lab?sim=custom:<id>`. БЕЗ копии (в отличие от «Моих
|
||||
материалов», где оригинал приватный и копия обязательна) и БЕЗ записи content_access.
|
||||
- **Привязка к программе:** переиспользован `lab_sim_links` с `sim_id='custom:<id>'` (sim_id TEXT —
|
||||
отдельная таблица не нужна). Связями СВОЕЙ симуляции управляет владелец/admin (не только admin как
|
||||
у lab_sims). Backend + GET `/related` готовы; UI-редактор связей + чипы в каталоге — остаток (handoff).
|
||||
- **Клон:** копия spec вызвавшему как draft (title += ' (копия)', version=1). Источник: своя любая
|
||||
ИЛИ чужая published (чужой draft → 403).
|
||||
- Верификация: `node --check` всех 6 изм. файлов OK; эмодзи нет (скан — только `→`/`∑` в комментариях,
|
||||
как в существующем коде); eval/Function нет; `npm run lint:routes` 0 unprotected (baseline 0);
|
||||
`npm test` 216/224 pass (8 fail = тот же baseline: 3 auth.test + 5 page-тестов без jsdom — не моя
|
||||
фаза; обе custom-sims-сьюты зелёные). git status: только мои файлы; classroom.html/lab.html не тронуты.
|
||||
- **Фаза 5 РЕАЛИЗОВАНА** (в рабочем дереве, не закоммичено — коммит за оркестратором). Только
|
||||
**аддитивные** правки двух файлов параллельной сессии (без рефактора их кода): рабочее дерево
|
||||
по ним было ЧИСТЫМ до начала. classroom.html / backend / `_sim_deps.js` НЕ тронуты.
|
||||
@@ -111,8 +135,12 @@
|
||||
- Reuse > переписывание: сначала смотреть `_fx_motion`, `_graph_panel`, `graph.js`.
|
||||
|
||||
## RESUME STATE
|
||||
- Последний коммит фичи: — (Ф0 + Ф1 + Ф2 + Ф3 + Ф4 + Ф5 реализованы, ещё не закоммичены — ждут оркестратора)
|
||||
- Текущая фаза: Phase 5 — Каталог (✅ Implemented, pending commit) → дальше Phase 6 — Раздача / шаблоны / клон / программа
|
||||
- Последний коммит фичи: — (Ф0..Ф6 реализованы, ещё не закоммичены — ждут оркестратора)
|
||||
- Текущая фаза: Phase 6 — Раздача / шаблоны / клон / программа (✅ Implemented, pending commit) →
|
||||
дальше Phase 7 — Доска онлайн-урока (последняя)
|
||||
- Эндпоинты Ф6: share/clone/related/links на `/api/custom-sims/:id/*`; клиент `LS.customSimShare/
|
||||
Clone/Related/AddLink/DelLink`. Раздача = авто-publish + pushNotif (НЕ копия). Связи — lab_sim_links
|
||||
`sim_id='custom:<id>'`. Остаток Ф6: UI-редактор связей в билдере + чипы в каталоге (backend готов).
|
||||
- Файлы Ф5 (аддитивные правки зоны параллельной сессии — БЕЗ рефактора): `frontend/js/labs/lab-init.js`
|
||||
(+7 строк: хук `LabCustom.resolveId` в `openSim`), `frontend/js/labs/lab-glue.js` (renderSims +`!m._custom`
|
||||
и вызов renderSection; init зовёт `LabCustom.init()`; новый IIFE `window.LabCustom`). `_sim_deps.js`,
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
- [x] Phase 3: БД + API (custom_sims) [domain: backend] → [subplan](./phase-3-persistence-api.md)
|
||||
- [x] Phase 4: Билдер (редактор) [domain: frontend] → [subplan](./phase-4-builder-ui.md)
|
||||
- [x] Phase 5: Каталог (custom-sims в /lab) [domain: fullstack] → [subplan](./phase-5-catalog.md)
|
||||
- [ ] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md)
|
||||
- [x] Phase 6: Раздача / шаблоны / клон / программа [domain: fullstack] → [subplan](./phase-6-sharing.md)
|
||||
- [ ] Phase 7: Доска онлайн-урока [domain: fullstack] → [subplan](./phase-7-classroom.md)
|
||||
|
||||
## Phase Progress Log
|
||||
@@ -58,7 +58,7 @@
|
||||
| Phase 3: Persistence + API | backend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 4: Builder UI | frontend | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 5: Catalog | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 6: Sharing | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 6: Sharing | fullstack | ✅ Done | ✅ | ✅ | ✅ |
|
||||
| Phase 7: Classroom | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 6: Раздача / шаблоны / клон / программа
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Implemented (pending commit — за оркестратором)
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** fullstack
|
||||
|
||||
@@ -9,16 +9,27 @@
|
||||
старт из шаблонов, привязка к программе (учебник/тема).
|
||||
|
||||
## Tasks
|
||||
- [ ] Публикация: тумблер draft↔published в билдере/каталоге (PUT status). Только владелец/админ.
|
||||
- [ ] Раздача классу: `POST /api/custom-sims/:id/share { classId }` — по паттерну «Мои материалы»
|
||||
(`shareMaterial`): ученики класса получают доступ/уведомление. Решить — ссылка-доступ или копия
|
||||
(рекоменд.: доступ-ссылка на published + запись в lab_sim_links/доступ; копия избыточна).
|
||||
- [ ] Клон: `POST /api/custom-sims/:id/clone` — копия спеки новому владельцу (draft). Кнопка «Клонировать» на чужой published-карточке.
|
||||
- [ ] Шаблоны: набор стартовых спек (встроенные JSON-фикстуры: пустая, маятник, график, бросок) →
|
||||
«Создать из шаблона» в билдере; «Создать из существующей» = clone.
|
||||
- [ ] Привязка к программе: переиспользовать `lab_sim_links` (kind=textbook|topic) для `custom:<id>`;
|
||||
чип «Связано с программой» (как у встроенных, `_loadRelated`) и кнопка «В лабораторию» с карточки учебника.
|
||||
- [ ] Тесты: share (доступ ученику), clone (новый владелец, draft), ownership на публикации.
|
||||
- [x] Публикация: тумблер draft↔published. В билдере — кнопка «Опубликовать»/«Снять» (PUT status,
|
||||
`setStatus`); в каталоге — кнопки на owner-карточке (publish/unpublish). Только владелец/admin.
|
||||
- [x] Раздача классу: `POST /api/custom-sims/:id/share { classId }` (requireRole teacher,admin +
|
||||
per-row ownership). РЕШЕНИЕ: published custom-sim И ТАК видна всем в каталоге, поэтому раздача =
|
||||
авто-публикация (status→published) + ДОЛГОВЕЧНОЕ уведомление ученикам класса (`pushNotif`,
|
||||
notifications-таблица + SSE) со ссылкой `/lab?sim=custom:<id>`. БЕЗ копии и БЕЗ content_access
|
||||
(custom-sim не гейтится allowlist'ом 'sim' — тот гейтит только legacy lab_sims).
|
||||
- [x] Клон: `POST /api/custom-sims/:id/clone` — копия spec вызвавшему как draft, title += « (копия)»,
|
||||
version=1. Источник: своя (любая) ИЛИ чужая published. Кнопка «Клонировать к себе» на чужой
|
||||
published-карточке (только для teacher/admin) → ведёт на `/sim-builder?id=<newId>`.
|
||||
- [x] Шаблоны: 4 встроенных спеки в `TEMPLATES` (sim-builder.js): пустая, маятник, график y=f(x),
|
||||
бросок. Кнопка «Шаблон» в тулбаре → модалка выбора → `loadFromSim` как новая симуляция.
|
||||
«Создать из существующей» = clone (с чужой карточки).
|
||||
- [x] Привязка к программе: переиспользован `lab_sim_links` с `sim_id='custom:<id>'` (sim_id —
|
||||
TEXT, отдельная таблица не нужна). Эндпоинты на роутере custom-sims: GET `/:id/related`,
|
||||
POST `/:id/links`, DELETE `/:id/links/:linkId` (владелец/admin управляет связями СВОЕЙ симуляции).
|
||||
Клиент: `customSimRelated/AddLink/DelLink`. Backend + чтение готовы; UI-редактор связей в билдере
|
||||
и чипы в каталоге — НЕ сделаны (см. Handoff: остаток).
|
||||
- [x] Тесты: `backend/tests/custom-sims-share.test.js` (15 it): share (авто-publish +
|
||||
durable-уведомление, 400/403/404), clone (новый владелец/draft/spec, чужой published OK, чужой
|
||||
draft 403), links (привязка/удаление/related, ownership). seedRow-паттерн (class/members/textbook).
|
||||
|
||||
## Files to Modify/Create
|
||||
- `backend/src/controllers/customSimController.js` — share/clone/publish (modify)
|
||||
@@ -38,10 +49,42 @@
|
||||
- Решение копия-vs-ссылка зафиксировать в CONTEXT.md.
|
||||
|
||||
## Review Checklist
|
||||
- [ ] Все задачи выполнены
|
||||
- [ ] Ownership на publish/share/clone покрыт тестами
|
||||
- [ ] Ученик класса получает доступ; чужой — нет
|
||||
- [ ] Reuse материалов/доступа/links, без дублей
|
||||
- [x] Все задачи выполнены (привязка к программе — backend+чтение; UI-редактор связей в Handoff)
|
||||
- [x] Ownership на publish/share/clone/links покрыт тестами (чужой draft → 403, чужой published clone → ОК)
|
||||
- [x] Ученик класса получает уведомление; не-владелец/не-свой-класс → 403
|
||||
- [x] Reuse материалов/доступа/links, без дублей (pushNotif, lab_sim_links, паттерн share из materials)
|
||||
|
||||
## Handoff to Next Phase
|
||||
<!-- Заполняет реализатор -->
|
||||
|
||||
### Что реализовано (Ф6)
|
||||
- **Backend** (`customSimController.js` + `customSims.js`):
|
||||
- `POST /:id/share { classId }` — авто-publish + `pushNotif(uid,'sim_shared',msg,'/lab?sim=custom:<id>')`
|
||||
каждому ученику класса. Возвращает `{ ok, sent, status:'published' }`. requireRole(teacher,admin) +
|
||||
ownership симуляции + проверка `classes.teacher_id`.
|
||||
- `POST /:id/clone` → 201 `{ id }`. Источник: своя (любая) ИЛИ чужая published. status=draft, version=1,
|
||||
title += ' (копия)', spec_json копируется как есть.
|
||||
- `GET /:id/related` (auth, own/published) / `POST /:id/links` / `DELETE /:id/links/:linkId`
|
||||
(owner/admin) — поверх `lab_sim_links`, `sim_id='custom:<id>'`. DELETE симуляции чистит её связи.
|
||||
- **Решение копия-vs-доступ:** доступ (published виден всем) + уведомление; НЕ копия, НЕ content_access.
|
||||
- **Клиент** (`js/api.js`): `customSimShare/Clone/Related/AddLink/DelLink` в `window.LS`.
|
||||
- **Каталог** (`lab-glue.js`, IIFE `LabCustom`, аддитивно): owner-карточка — кнопки Раздать классу /
|
||||
Опубликовать↔Снять; чужая published (для teacher/admin) — «Клонировать к себе». Делегат `data-act`
|
||||
расширен (share/clone/publish/unpublish). Модалка выбора класса (`LS.getClasses`+`LS.modal`).
|
||||
Публичное API: добавлены `LabCustom.share/clone/setStatus`.
|
||||
- **Билдер** (`sim-builder.js`): тулбар — «Шаблон» (TEMPLATES×4: пустая/маятник/график/бросок →
|
||||
`loadFromSim` как новая), «Раздать» (для сохранённой), publish-toggle (Опубликовать↔Снять).
|
||||
Новые методы: `setStatus`, `openShareModal`, `openTemplateModal`. ICON.template/unpublish.
|
||||
|
||||
### Остаток (минимум по плану — не сделано, низкий приоритет)
|
||||
- UI-редактор курикулумных связей в билдере (select учебника из `/api/access/catalog` + добавить/
|
||||
удалить) и чипы «Связано с программой» в каталоге/на странице sim. Backend и `/related` готовы —
|
||||
это чисто фронтовая надстройка. Обратный поиск «какие custom-sim привязаны к учебнику» (как
|
||||
`/api/lab/links?kind=textbook&ref_id=`) для custom НЕ добавлен: `/api/lab/links` джойнит `lab_sims`,
|
||||
а у custom строк там нет — при желании добавить отдельный bulk-эндпоинт или LEFT JOIN на custom_sims.
|
||||
|
||||
### Для Ф7 (доска онлайн-урока)
|
||||
- Источник sim для доски: `LS.customSimGet(id)` → `sim.spec`; рендер — `window.SimEngine.mount(host, spec)`
|
||||
(как в билдере/каталоге). Deep-link/идентификатор: `custom:<dbid>`; в LabRegistry — `customsim_<dbid>`
|
||||
(resolveId). published-симуляция доступна всем (для учеников на доске — без доп. прав).
|
||||
- Раздача классу уже шлёт уведомление со ссылкой `/lab?...` — на доске ссылку при необходимости
|
||||
заменить на классную сессию; механизм `pushNotif` переиспользуем.
|
||||
|
||||
Reference in New Issue
Block a user