From cbb6edf372d48b79478b1e231c1900a267beb6d3 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 13 Jun 2026 13:06:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(sim-builder):=20=D1=84=D0=B0=D0=B7=D0=B0?= =?UTF-8?q?=206=20=E2=80=94=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B0=D1=87=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=83,=20=D0=BA=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD,=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D1=8B,=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B0=20=D0=BA=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D1=80=D0=B0=D0=BC=D0=BC=D0=B5=20(custom=5Fsi?= =?UTF-8?q?ms)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 10 + .../src/controllers/customSimController.js | 184 +++++++++++++- backend/src/routes/customSims.js | 11 + backend/tests/custom-sims-share.test.js | 225 ++++++++++++++++++ frontend/js/labs/lab-glue.js | 115 ++++++++- frontend/js/sim-builder.js | 171 ++++++++++++- js/api.js | 6 + plans/sim-builder/CONTEXT.md | 32 ++- plans/sim-builder/PLAN.md | 4 +- plans/sim-builder/phase-6-sharing.md | 75 ++++-- 10 files changed, 803 insertions(+), 30 deletions(-) create mode 100644 backend/tests/custom-sims-share.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 985c34c..cdbf5a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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_')` дергает `/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:` (Ф5 deep-link). +- **`lab_sim_links.sim_id` — TEXT** (см. мигр.043), поэтому курикулумные связи custom переиспользуют ту же таблицу с `sim_id='custom:'` — отдельная таблица не нужна. Связями СВОЕЙ симуляции рулит владелец/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`, без эмодзи. diff --git a/backend/src/controllers/customSimController.js b/backend/src/controllers/customSimController.js index 110e3ed..6f6520b 100644 --- a/backend/src/controllers/customSimController.js +++ b/backend/src/controllers/customSimController.js @@ -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:'). + 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: (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:'. + 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, +}; diff --git a/backend/src/routes/customSims.js b/backend/src/routes/customSims.js index c89b430..db1d9e8 100644 --- a/backend/src/routes/customSims.js +++ b/backend/src/routes/customSims.js @@ -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; diff --git a/backend/tests/custom-sims-share.test.js b/backend/tests/custom-sims-share.test.js new file mode 100644 index 0000000..fc8aa93 --- /dev/null +++ b/backend/tests/custom-sims-share.test.js @@ -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:. + 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:') ──────────── */ + 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}`); + }); + }); +}); diff --git a/frontend/js/labs/lab-glue.js b/frontend/js/labs/lab-glue.js index 03dcc09..6b78f4f 100644 --- a/frontend/js/labs/lab-glue.js +++ b/frontend/js/labs/lab-glue.js @@ -715,6 +715,21 @@ const SIMS = [ var _EDIT_ICON = ''; var _DEL_ICON = ''; + var _SHARE_ICON = ''; + var _CLONE_ICON = ''; + var _PUB_ICON = ''; + var _UNPUB_ICON = ''; + + 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 ''; + } function _cardHtml(m) { var owner = _isOwner(m); @@ -726,14 +741,26 @@ const SIMS = [ else if (owner) badges += 'Черновик'; 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 = '
' + - '' + - '' + + _btn('edit', m.id, _EDIT_ICON + 'Редактировать', STYLE_PRI) + + _btn('del', m.id, _DEL_ICON, STYLE_DEL, 'Удалить') + + '
' + + '
' + + _btn('share', m.id, _SHARE_ICON + 'Раздать классу', STYLE_GHOST + ';flex:1') + + pubBtn + + '
'; + } else if (published && _isTeacherUser()) { + actions = + '
' + + _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)') + '
'; } var preview = '' + @@ -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:). + 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 ''; + }).join(''); + var content = '
' + + '' + + '' + + '
Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.
' + + '
'; + 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 }; })(); diff --git a/frontend/js/sim-builder.js b/frontend/js/sim-builder.js index 5d9c102..bf1a138 100644 --- a/frontend/js/sim-builder.js +++ b/frontend/js/sim-builder.js @@ -320,16 +320,26 @@ var statusBadge = this.status === 'published' ? 'Опубликовано' : 'Черновик'; + // Кнопка публикации: для опубликованной — «Снять с публикации»; иначе «Опубликовать». + var pubBtn = this.status === 'published' + ? '' + : ''; + // «Раздать классу» доступна только для уже сохранённой симуляции. + var shareBtn = this.simId + ? '' + : ''; t.innerHTML = '
' + '' + (this.simId ? 'Редактор симуляции' : 'Новая симуляция') + '' + statusBadge + '
' + '
' + + '' + '' + '' + '' + - '' + + shareBtn + + pubBtn + '
'; 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:). */ + 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 ''; + }).join(''); + var content = '
' + + '' + + '' + + '
Ученики класса получат уведомление со ссылкой. Симуляция будет автоматически опубликована.
' + + '
'; + 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 ''; + }).join(''); + var content = '
' + cards + + '
Шаблон заменит текущую сцену и создаст новую симуляцию.
'; + 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: '', chev: '', target: '', - cog: '' + cog: '', + template: '', + unpublish: '' }; + /* ── Встроенные шаблоны стартовых спек (Фаза 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; diff --git a/js/api.js b/js/api.js index 5faf84e..4f19df5 100644 --- a/js/api.js +++ b/js/api.js @@ -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 }); } diff --git a/plans/sim-builder/CONTEXT.md b/plans/sim-builder/CONTEXT.md index fa1a40c..1da9e0f 100644 --- a/plans/sim-builder/CONTEXT.md +++ b/plans/sim-builder/CONTEXT.md @@ -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:`. БЕЗ копии (в отличие от «Моих + материалов», где оригинал приватный и копия обязательна) и БЕЗ записи content_access. + - **Привязка к программе:** переиспользован `lab_sim_links` с `sim_id='custom:'` (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:'`. Остаток Ф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`, diff --git a/plans/sim-builder/PLAN.md b/plans/sim-builder/PLAN.md index d3edb33..09da701 100644 --- a/plans/sim-builder/PLAN.md +++ b/plans/sim-builder/PLAN.md @@ -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 diff --git a/plans/sim-builder/phase-6-sharing.md b/plans/sim-builder/phase-6-sharing.md index 7e8fa2a..bb40e82 100644 --- a/plans/sim-builder/phase-6-sharing.md +++ b/plans/sim-builder/phase-6-sharing.md @@ -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:`; - чип «Связано с программой» (как у встроенных, `_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:`. БЕЗ копии и БЕЗ 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=`. +- [x] Шаблоны: 4 встроенных спеки в `TEMPLATES` (sim-builder.js): пустая, маятник, график y=f(x), + бросок. Кнопка «Шаблон» в тулбаре → модалка выбора → `loadFromSim` как новая симуляция. + «Создать из существующей» = clone (с чужой карточки). +- [x] Привязка к программе: переиспользован `lab_sim_links` с `sim_id='custom:'` (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:')` + каждому ученику класса. Возвращает `{ 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:'`. 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:`; в LabRegistry — `customsim_` + (resolveId). published-симуляция доступна всем (для учеников на доске — без доп. прав). +- Раздача классу уже шлёт уведомление со ссылкой `/lab?...` — на доске ссылку при необходимости + заменить на классную сессию; механизм `pushNotif` переиспользуем.