97 lines
7.9 KiB
Markdown
97 lines
7.9 KiB
Markdown
# Phase 3: БД + API (custom_sims)
|
|
|
|
**Status:** ✅ Implemented (в рабочем дереве, не закоммичено — коммит за оркестратором)
|
|
**Parent plan:** [PLAN.md](./PLAN.md)
|
|
**Domain:** backend
|
|
|
|
## Objective
|
|
Сохранение custom-симуляций: таблица БД, CRUD API под авторизацией с проверкой владения,
|
|
серверная валидация спеки. После фазы спека сохраняется/грузится/удаляется через API.
|
|
|
|
## Tasks
|
|
- [x] Миграция `backend/src/db/migrations/071_custom_sims.sql`: таблица `custom_sims`
|
|
(id PK AUTOINCREMENT, owner_id FK users ON DELETE CASCADE, title, description, subject,
|
|
grade, cat, spec_json TEXT NOT NULL, status TEXT 'draft|published' DEFAULT 'draft',
|
|
version INT DEFAULT 1, created_at, updated_at) + индексы idx_custom_sims_owner / _status.
|
|
Идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS).
|
|
- [x] Контроллер `backend/src/controllers/customSimController.js`: list (own + чужой published),
|
|
get (own ИЛИ published), create (через роут teacher/admin), update, remove. Владение
|
|
проверяется на каждой мутации (owner ИЛИ admin → иначе 403; нет строки → 404). version++ на update.
|
|
- [x] Серверная валидация спеки `validateSpec(spec)`: размер JSON (≤200KB), `specVersion`=1,
|
|
лимиты (params≤50, objects≤200, walls≤20, springs≤50, expr≤500 симв., глубина≤8, points≤1000),
|
|
типы объектов из whitelist (point|segment|vector|circle|rect|polyline|path|label|plot|readout),
|
|
physics (restitution 0..1, dt 1/2000..1/30, body.mass>0), строки-подписи (text/label/unit/meta/
|
|
drag.param/param.label) санитизируются как текст (escape &<>, обрезка). Возврат {ok,error?,clean?}.
|
|
⛔ Спека НЕ исполняется на сервере (нет eval/Function/SimExpr).
|
|
- [x] Роуты `backend/src/routes/customSims.js`: router-level `authMiddleware` (read auth-only);
|
|
`GET /` + `GET /:id` (видимость в хендлере); `POST /`, `PUT /:id`, `DELETE /:id` — inline
|
|
`requireRole('teacher','admin')`. Смонтировано в server.js: `app.use('/api/custom-sims', ...)`.
|
|
- [x] Клиент `js/api.js`: `customSimsList/Get/Create/Update/Delete` → `req(...)` + добавлены в `window.LS`.
|
|
- [x] Тесты `backend/tests/custom-sims.test.js` (24/24 pass): CRUD happy-path, ownership (чужой
|
|
PUT/DELETE → 403), get чужой draft → 403, get чужой published → 200, admin override, 404,
|
|
validateSpec (12 кейсов отказа 400), санитизация. Монтирует роут на shared test-app (как lab-links).
|
|
|
|
## Files to Modify/Create
|
|
- `backend/src/db/migrations/0NN_custom_sims.sql` (new)
|
|
- `backend/src/controllers/customSimController.js` (new)
|
|
- `backend/src/routes/customSims.js` (new)
|
|
- `backend/src/server.js` — монтирование роутера (modify)
|
|
- `js/api.js` — клиентские методы (modify)
|
|
- `backend/tests/custom-sims.test.js` (new)
|
|
|
|
## Acceptance Criteria
|
|
- POST сохраняет спеку, GET возвращает свои+published, PUT/DELETE только владельцу/админу (403 иначе).
|
|
- Кривая/огромная спека → 400. Тесты зелёные (в пределах baseline).
|
|
- `npm run migrate` применяет таблицу; роут не отдаёт SPA-fallback после рестарта.
|
|
|
|
## Notes
|
|
- node:sqlite (DatabaseSync), НЕ better-sqlite3.
|
|
- Спека хранится как TEXT(JSON); парс/валидация — на входе.
|
|
- Не делать blanket `router.use(requireRole('admin'))` — read-роуты auth-only, мутации — inline requireRole.
|
|
|
|
## Review Checklist
|
|
- [x] Все задачи выполнены
|
|
- [x] Ownership и валидация спеки покрыты тестами (24/24)
|
|
- [x] Миграция идемпотентна (CREATE TABLE/INDEX IF NOT EXISTS)
|
|
- [x] `npm run lint:routes` чисто (0 unprotected, baseline 0); тесты в пределах baseline
|
|
|
|
## Handoff to Next Phase
|
|
|
|
### Что реализовано (Phase 3)
|
|
- **Миграция 071** `custom_sims` (применена к живой БД `npm run migrate` без ошибок).
|
|
- **API `/api/custom-sims`** (смонтировано в `backend/src/server.js` после `/api/materials`):
|
|
| Метод | Путь | Доступ | Ответ |
|
|
|-------|------|--------|-------|
|
|
| GET | `/api/custom-sims` | auth | `{ sims:[{id,owner_id,title,description,subject,grade,cat,status,version,created_at,updated_at}] }` (свои любого статуса + чужие published; БЕЗ spec) |
|
|
| GET | `/api/custom-sims/:id` | auth (own ИЛИ published) | `{ sim:{...meta, spec} }` (spec уже распарсен из JSON); чужой draft → 403, нет → 404 |
|
|
| POST | `/api/custom-sims` | teacher/admin | `201 { id }`; принимает `{ title?, description?, subject?, grade?, cat?, status?, spec }`; кривая спека → 400 |
|
|
| PUT | `/api/custom-sims/:id` | owner/admin | `200 { ok:true }`; любое поле опц.; `spec` валидируется заново и version++; 403/404 |
|
|
| DELETE | `/api/custom-sims/:id` | owner/admin | `200 { ok:true }`; 403/404 |
|
|
- **Клиентские методы** (`js/api.js` → `window.LS`): `customSimsList()`, `customSimGet(id)`,
|
|
`customSimCreate(data)`, `customSimUpdate(id, data)`, `customSimDelete(id)`.
|
|
|
|
### Форма записи `custom_sims`
|
|
`id`, `owner_id`(FK users CASCADE), `title`, `description`, `subject`, `grade`(INT), `cat`
|
|
(math|phys|chem|bio|game|NULL), `spec_json`(TEXT — валидированный JSON), `status`(draft|published,
|
|
деф. draft), `version`(INT, ++ на каждом update со spec), `created_at`, `updated_at`.
|
|
|
|
### validateSpec — контракт для Ф4 (билдер)
|
|
Билдер должен слать в `spec` объект формата v1 (specVersion:1, meta, viewport, params[], objects[],
|
|
physics?). Сервер вернёт **400** при: spec не объект; specVersion≠1; >200KB JSON; глубина >8;
|
|
params>50; objects>200; walls>20; springs>50; строка-выражение >500 симв.; недопустимый type
|
|
объекта (вне whitelist point|segment|vector|circle|rect|polyline|path|label|plot|readout);
|
|
restitution вне 0..1; dt вне 1/2000..1/30; body.mass≤0. Текстовые поля (`text`,`label`,`unit`,
|
|
`meta.title/desc`, `param.label/unit/name`, `drag.param/paramY`, `id`) **обрезаются и
|
|
экранируются** (`& < >` → entities) — билдер получит обратно очищенную спеку в `GET /:id`.
|
|
⚠️ Не использовать имя param `e` (зарезервировано в SimExpr — см. Ф2).
|
|
|
|
### На Ф4 (билдер: какие поля шлёт/получает)
|
|
- **Создание**: `LS.customSimCreate({ title, description?, subject?, grade?, cat?, status?, spec })`
|
|
→ `{ id }`. После — `LS.customSimUpdate(id, { spec, status:'published' })` для публикации.
|
|
- **Загрузка для редактирования**: `LS.customSimGet(id)` → `{ sim }`, где `sim.spec` — готовый
|
|
объект для монтирования в `SimEngine.mount(host, sim.spec)`.
|
|
- **Список «мои/опубликованные»**: `LS.customSimsList()` → `{ sims }` (метаданные без spec; spec
|
|
тянуть лениво по `customSimGet`).
|
|
- Билдер должен учитывать, что сервер вернёт спеку **санитизированной** (escaped-текст) — для
|
|
KaTeX/canvas это безопасно, но при сравнении «до/после» учитывать экранирование.
|