feat(access): Фаза 1a — видимость симуляций по классам (добавочная модель)

Миграция 051: расширяет content_access.content_type на 'course'/'sim' (пересборка
таблицы — SQLite не умеет ALTER CHECK) + мост «открыть все включённые симуляции
всем существующим классам» → текущее поведение не меняется. GET /api/lab/sims
теперь фильтрует список для НЕпривилегированных по allowedRefs(uid,'sim'); admin/
teacher видят все. Ролевой simulations.access остаётся «модуль вкл.» (добавочно).
Тесты: lab-access (4/4, allowlist+класс+личное), lab-sims переведён на admin для
проверки полного каталога (видимость ученика — в lab-access). /api/lab в харнессе.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 13:19:29 +03:00
parent 16d0f91622
commit 9a145e5d62
5 changed files with 115 additions and 9 deletions
@@ -0,0 +1,40 @@
-- 051_content_access_types.sql
-- Расширяем content_access.content_type на 'course' и 'sim' (добавочная модель
-- видимости контента по классам/ученикам — см. plans/access-redesign/PLAN.md).
--
-- SQLite не поддерживает ALTER TABLE ... DROP/ALTER CONSTRAINT, поэтому
-- пересобираем таблицу с расширенным CHECK, сохраняя данные, UNIQUE и индексы.
-- Затем мост: открываем все ВКЛЮЧЁННЫЕ симуляции всем существующим классам
-- (как миграция 040 сделала для учебников) — текущее поведение не меняется.
--
-- В этой миграции wired только тип 'sim' (фильтр в /api/lab/sims). Тип 'course'
-- уже разрешён CHECK-ом на будущее, но мост/гейт курсов добавим отдельным шагом
-- (у курсов своя логика публикации и class_courses).
CREATE TABLE content_access_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_type TEXT NOT NULL CHECK (content_type IN ('textbook','exam','course','sim')),
content_ref TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('class','student')),
target_id INTEGER NOT NULL,
allow INTEGER NOT NULL DEFAULT 1 CHECK (allow IN (0,1)),
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (content_type, content_ref, scope, target_id)
);
INSERT INTO content_access_new (id, content_type, content_ref, scope, target_id, allow, created_by, created_at)
SELECT id, content_type, content_ref, scope, target_id, allow, created_by, created_at
FROM content_access;
DROP TABLE content_access;
ALTER TABLE content_access_new RENAME TO content_access;
CREATE INDEX IF NOT EXISTS idx_content_access_lookup ON content_access (content_type, content_ref);
CREATE INDEX IF NOT EXISTS idx_content_access_target ON content_access (content_type, scope, target_id);
-- Мост: все включённые симуляции — всем существующим классам (allowlist-переход).
INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow)
SELECT 'sim', s.id, 'class', c.id, 1
FROM lab_sims s CROSS JOIN classes c
WHERE s.enabled = 1;
+10 -2
View File
@@ -16,6 +16,7 @@
const router = require('express').Router();
const db = require('../db/db');
const { authMiddleware, requireRole } = require('../middleware/auth');
const access = require('../services/contentAccess');
const CATS = ['math', 'phys', 'chem', 'bio', 'game'];
const LINK_KINDS = ['textbook', 'topic', 'kmap', 'question'];
@@ -48,7 +49,7 @@ function rowToSim(r) {
}
/* ── GET /api/lab/sims ─────────────────────────────────────────────────── */
router.get('/sims', (_req, res) => {
router.get('/sims', (req, res) => {
let rows;
try {
rows = db.prepare(`SELECT * FROM lab_sims ORDER BY sort_order, id`).all();
@@ -60,12 +61,19 @@ router.get('/sims', (_req, res) => {
return res.json({ module_disabled: readModuleDisabled(), sims: [], needs_migration: true });
}
const legacyDisabled = readLegacyDisabledIds();
const sims = rows.map(r => {
let sims = rows.map(r => {
const s = rowToSim(r);
// Симуляция считается выключенной, если так сказано в lab_sims ИЛИ в legacy-списке.
s.enabled = s.enabled && !legacyDisabled.has(r.id);
return s;
});
// Видимость по классам (добавочная модель): ролевой simulations.access решает,
// включён ли модуль вообще (проверяется на фронте/при действиях); здесь ученик
// видит только разрешённые его классу/лично симуляции. admin/teacher — все.
if (req.user && !access.PRIVILEGED.has(req.user.role)) {
const allowed = access.allowedRefs(req.user.id, 'sim');
sims = sims.filter(s => allowed.has(s.id));
}
res.json({ module_disabled: readModuleDisabled(), sims });
});