feat(access): доступ к учебникам и экзаменам по классам/ученикам из админ-панели

Модель allowlist (закрыто по умолчанию), правило ученика важнее класса.
Управляют админ (все) и учителя (свои классы/ученики).

- миграция 040: таблица content_access + непрерывный переход
  (всем существующим классам открыт текущий контент)
- сервис contentAccess: резолвинг доступа, главы наследуют хаб
- API /api/access (catalog/targets/rules) для admin+teacher
- гейты: каталог учебников, router.param slug/examKey, фильтр tracks
- клиентские редиректы на /403 (textbook-tracker, exam-prep boot)
- раздел админки «Доступ к учебникам»: классы + ученики (tri-state)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-30 12:33:05 +03:00
parent 98f955a85e
commit 471171b77c
12 changed files with 564 additions and 4 deletions
@@ -0,0 +1,44 @@
-- 040_content_access.sql
-- Per-class / per-student access control for textbooks and exam modules.
--
-- Model (chosen 2026-05-30): ALLOWLIST — content is hidden by default and must
-- be explicitly opened for a class or a student. A student-level rule always
-- overrides the class-level rule (точечные исключения). allow = 1 → открыт,
-- allow = 0 → закрыт (используется только как индивидуальное исключение).
--
-- content_ref:
-- • content_type='textbook' → top-level textbook slug (parent_slug IS NULL).
-- Главы (parent_slug != NULL) наследуют доступ родителя.
-- • content_type='exam' → exam_tracks.exam_key (например 'math9').
CREATE TABLE IF NOT EXISTS content_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_type TEXT NOT NULL CHECK (content_type IN ('textbook','exam')),
content_ref TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('class','student')),
target_id INTEGER NOT NULL, -- class_id (scope=class) или user_id (scope=student)
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)
);
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 'textbook', t.slug, 'class', c.id, 1
FROM textbooks t CROSS JOIN classes c
WHERE t.is_active = 1 AND t.parent_slug IS NULL;
INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow)
SELECT 'exam', e.exam_key, 'class', c.id, 1
FROM exam_tracks e CROSS JOIN classes c
WHERE e.enabled = 1;