feat(prep): мастер-флаг подготовки к направлению (ЦТ) + коллекции колод — бэкенд

Система «готовится к ЦТ»: флаг student_prep(user_id,track) открывает ученику
ВЕСЬ контент трека (карточки + курс + пробники) динамически, без материализации.
- мигр.078: таблица student_prep + flashcard_decks.collection + разметка ЦТ-колод 'ct-math'
- services/prepTracks.js: реестр треков (трек→коллекция/курсы/экзамены), устойчив до миграции
- contentAccess.resolve/allowedRefs: учитывают мастер-флаг (явный запрет ученика побеждает)
- flashcardController.deckAccess/listDecks: колоды коллекции открыты по флагу
- prepController + /api/prep: учитель (своим) и админ ставят/снимают флаг (ученику/классу)
- js/api.js: LS.prep* обёртки

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-19 15:29:00 +03:00
parent 5193fd8252
commit 9509a67e25
8 changed files with 267 additions and 4 deletions
+11 -2
View File
@@ -1,5 +1,6 @@
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
const prepTracks = require('../services/prepTracks');
/* ── валидация URL картинки ────────────────────────────────────────────────
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
@@ -110,7 +111,10 @@ function deckAccess(deckId, user) {
(a.type = 'class' AND a.target_id IN (SELECT class_id FROM class_members WHERE user_id = ?))
) LIMIT 1
`).get(deckId, user.id, user.id);
return { deck, owner: false, canRead: !!shared, canEdit: false };
// Колода открыта мастер-флагом направления: collection ∈ коллекций треков ученика.
const byTrack = !shared && deck.collection &&
prepTracks.studentCollections(user.id).has(deck.collection);
return { deck, owner: false, canRead: !!shared || !!byTrack, canEdit: false };
}
/* due_count карты колоды для пользователя: learning/review к повтору (due_at<=now)
@@ -140,6 +144,10 @@ function deckDueCount(deckId, uid) {
для UI: общие открываются только на чтение и изучение. */
function listDecks(req, res) {
const uid = req.user.id;
// Коллекции, открытые мастер-флагом направления (динамически, без правил доступа).
const cols = [...prepTracks.studentCollections(uid)];
const colClause = cols.length
? `OR d.collection IN (${cols.map(() => '?').join(',')})` : '';
const decks = db.prepare(`
SELECT d.*, u.name AS owner_name,
CASE WHEN d.user_id = ? THEN 1 ELSE 0 END AS can_edit,
@@ -152,8 +160,9 @@ function listDecks(req, res) {
OR EXISTS (SELECT 1 FROM flashcard_deck_access a
JOIN class_members cm ON cm.class_id = a.target_id AND cm.user_id = ?
WHERE a.deck_id = d.id AND a.type = 'class')
${colClause}
ORDER BY shared ASC, d.created_at DESC
`).all(uid, uid, uid, uid, uid);
`).all(uid, uid, uid, uid, uid, ...cols);
const cardStmt = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_cards WHERE deck_id = ?`);
for (const d of decks) {
+113
View File
@@ -0,0 +1,113 @@
'use strict';
/* prepController — управление флагом «подготовки к направлению» (мастер-флаг).
*
* Флаг student_prep(user_id, track) открывает ученику весь контент трека
* (карточки + курс + пробники) — резолв в services/prepTracks.js + contentAccess.js.
*
* Управление: учитель — СВОИМ ученикам (из своих классов или персональным),
* админ — любым. Read own-статус доступен самому ученику (GET /me).
*/
const db = require('../db/db');
const prepTracks = require('../services/prepTracks');
/* Ученик «свой» для учителя: член одного из его классов ИЛИ персональный ученик. */
function canManageStudent(user, studentId) {
if (user.role === 'admin') return true;
if (user.role !== 'teacher') return false;
return !!db.prepare(`
SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id
WHERE cm.user_id = ? AND c.teacher_id = ?
UNION
SELECT 1 FROM teacher_students WHERE student_id = ? AND teacher_id = ?
LIMIT 1
`).get(studentId, user.id, studentId, user.id);
}
/* Класс «свой» для учителя (admin — любой). */
function canManageClass(user, classId) {
if (user.role === 'admin') return true;
if (user.role !== 'teacher') return false;
return !!db.prepare(`SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?`).get(classId, user.id);
}
/* ── GET /api/prep/tracks — список доступных направлений (для UI-меток) ── */
function listTracks(req, res) {
res.json({ tracks: prepTracks.listTracks() });
}
/* ── GET /api/prep/me — мои треки (ученик видит свой статус) ── */
function myTracks(req, res) {
const rows = db.prepare(`SELECT track FROM student_prep WHERE user_id = ?`).all(req.user.id);
res.json({ tracks: rows.map(r => r.track).filter(prepTracks.isTrack) });
}
/* ── GET /api/prep/student/:id — треки ученика (учитель своего / админ) ── */
function studentTracks(req, res) {
const studentId = Number(req.params.id) || 0;
if (!canManageStudent(req.user, studentId)) return res.status(403).json({ error: 'Forbidden' });
const rows = db.prepare(`SELECT track FROM student_prep WHERE user_id = ?`).all(studentId);
res.json({ tracks: rows.map(r => r.track).filter(prepTracks.isTrack) });
}
/* ── POST /api/prep/student/:id { track } — включить флаг ── */
function setStudent(req, res) {
const studentId = Number(req.params.id) || 0;
const track = String(req.body.track || '');
if (!prepTracks.isTrack(track)) return res.status(400).json({ error: 'Неизвестный трек' });
if (!canManageStudent(req.user, studentId)) return res.status(403).json({ error: 'Forbidden' });
// Ставим флаг только реально существующему ученику.
const stu = db.prepare(`SELECT id, role FROM users WHERE id = ?`).get(studentId);
if (!stu) return res.status(404).json({ error: 'Ученик не найден' });
db.prepare(`INSERT OR IGNORE INTO student_prep (user_id, track, created_by) VALUES (?,?,?)`)
.run(studentId, track, req.user.id);
res.json({ ok: true });
}
/* ── DELETE /api/prep/student/:id?track= — снять флаг ── */
function unsetStudent(req, res) {
const studentId = Number(req.params.id) || 0;
const track = String(req.query.track || '');
if (!prepTracks.isTrack(track)) return res.status(400).json({ error: 'Неизвестный трек' });
if (!canManageStudent(req.user, studentId)) return res.status(403).json({ error: 'Forbidden' });
db.prepare(`DELETE FROM student_prep WHERE user_id = ? AND track = ?`).run(studentId, track);
res.json({ ok: true });
}
/* ── GET /api/prep/class/:id?track= — статус флага у членов класса ── */
function classStatus(req, res) {
const classId = Number(req.params.id) || 0;
const track = String(req.query.track || '');
if (!prepTracks.isTrack(track)) return res.status(400).json({ error: 'Неизвестный трек' });
if (!canManageClass(req.user, classId)) return res.status(403).json({ error: 'Forbidden' });
const rows = db.prepare(`
SELECT u.id, u.name,
CASE WHEN sp.id IS NULL THEN 0 ELSE 1 END AS prep
FROM class_members cm
JOIN users u ON u.id = cm.user_id
LEFT JOIN student_prep sp ON sp.user_id = u.id AND sp.track = ?
WHERE cm.class_id = ?
ORDER BY u.name
`).all(track, classId);
res.json({ students: rows });
}
/* ── POST /api/prep/class/:id { track, on } — массово по классу ── */
function setClass(req, res) {
const classId = Number(req.params.id) || 0;
const track = String(req.body.track || '');
const on = !!req.body.on;
if (!prepTracks.isTrack(track)) return res.status(400).json({ error: 'Неизвестный трек' });
if (!canManageClass(req.user, classId)) return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`SELECT user_id FROM class_members WHERE class_id = ?`).all(classId);
const ins = db.prepare(`INSERT OR IGNORE INTO student_prep (user_id, track, created_by) VALUES (?,?,?)`);
const del = db.prepare(`DELETE FROM student_prep WHERE user_id = ? AND track = ?`);
const run = db.transaction(() => {
for (const m of members) on ? ins.run(m.user_id, track, req.user.id) : del.run(m.user_id, track);
});
run();
res.json({ ok: true, count: members.length });
}
module.exports = {
listTracks, myTracks, studentTracks, setStudent, unsetStudent, classStatus, setClass,
};
@@ -0,0 +1,28 @@
-- 078_prep_tracks.sql
-- Система «подготовка к направлению» (ЦТ и др.): флаг ученика + коллекция колод.
--
-- МАСТЕР-ФЛАГ: запись student_prep(user_id, track) открывает ученику ВЕСЬ контент
-- трека сразу — коллекцию флешкарт (flashcard_decks.collection), курс и пробники
-- (content_type='course'/'exam'). Маппинг трек→контент — services/prepTracks.js;
-- динамический резолв доступа — services/contentAccess.js (без материализации
-- правил content_access) и flashcardController.deckAccess/listDecks.
--
-- Управление флагом — учитель (своим ученикам) и админ (см. prepController.js).
-- 1) Флаг подготовки ученика по треку. track — ключ направления ('ct-math', …).
CREATE TABLE IF NOT EXISTS student_prep (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
track TEXT NOT NULL,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (user_id, track)
);
CREATE INDEX IF NOT EXISTS idx_student_prep_user ON student_prep(user_id);
CREATE INDEX IF NOT EXISTS idx_student_prep_track ON student_prep(track);
-- 2) Коллекция (папка/направление) колоды. NULL = вне коллекции (поведение как раньше).
ALTER TABLE flashcard_decks ADD COLUMN collection TEXT;
-- 3) Разметить существующие ЦТ-колоды коллекцией 'ct-math' (заголовки «ЦТ · …»).
UPDATE flashcard_decks SET collection = 'ct-math' WHERE title LIKE 'ЦТ ·%';
+22
View File
@@ -0,0 +1,22 @@
'use strict';
/* /api/prep — управление мастер-флагом «подготовка к направлению».
* Все роуты под authMiddleware. Мутации/чтение чужого статуса — учитель (своих)
* или админ (проверка владения в контроллере: canManageStudent/canManageClass). */
const express = require('express');
const router = express.Router();
const prep = require('../controllers/prepController');
const { authMiddleware, requireRole } = require('../middleware/auth');
router.use(authMiddleware);
router.get ('/tracks', prep.listTracks); // справочник направлений (любой авторизованный)
router.get ('/me', prep.myTracks); // свой статус (ученик)
// Управление флагами учеников/классов — только учитель/админ (владение — в контроллере).
router.get ('/student/:id', requireRole('teacher', 'admin'), prep.studentTracks);
router.post ('/student/:id', requireRole('teacher', 'admin'), prep.setStudent);
router.delete('/student/:id', requireRole('teacher', 'admin'), prep.unsetStudent);
router.get ('/class/:id', requireRole('teacher', 'admin'), prep.classStatus);
router.post ('/class/:id', requireRole('teacher', 'admin'), prep.setClass);
module.exports = router;
+1
View File
@@ -198,6 +198,7 @@ app.use('/api/lab', labRoutes);
app.use('/api/materials', require('./routes/materials'));
app.use('/api/custom-sims', require('./routes/customSims'));
app.use('/api/game', require('./routes/game'));
app.use('/api/prep', require('./routes/prep'));
app.use('/api/dashboard', require('./routes/dashboard'));
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
+5 -2
View File
@@ -10,6 +10,7 @@
* allowedRefs(userId, type) → Set<content_ref> доступных пользователю
*/
const db = require('../db/db');
const prepTracks = require('./prepTracks');
const PRIVILEGED = new Set(['admin', 'teacher']);
@@ -33,9 +34,10 @@ const _classRule = db.prepare(`
function resolve(userId, type, ref) {
const s = _studentRule.get(type, ref, userId);
if (s) return s.allow === 1; // правило ученика побеждает
if (s) return s.allow === 1; // явное правило ученика побеждает (вкл. запрет)
const c = _classRule.get(type, ref, userId);
if (c && c.n > 0) return c.any_allow === 1; // открыт хотя бы одним классом
if (prepTracks.trackGrants(userId, type, ref)) return true; // мастер-флаг направления
return false; // allowlist — по умолчанию закрыто
}
@@ -69,9 +71,10 @@ function allowedRefs(userId, type) {
for (const r of _allClassRules.all(type, userId)) {
if (r.any_allow === 1) out.add(r.content_ref);
}
for (const r of prepTracks.trackRefs(userId, type)) out.add(r); // мастер-флаг направления открывает
for (const r of _allStudentRules.all(type, userId)) {
if (r.allow === 1) out.add(r.content_ref); // ученик-разрешение добавляет
else out.delete(r.content_ref); // ученик-запрет снимает
else out.delete(r.content_ref); // ученик-запрет снимает (в т.ч. поверх трека)
}
return out;
}
+78
View File
@@ -0,0 +1,78 @@
'use strict';
/* prepTracks — направления подготовки (ЦТ и др.) и их контент («мастер-флаг»).
*
* Флаг student_prep(user_id, track) открывает ученику ВЕСЬ контент трека:
* - коллекцию флешкарт (flashcard_decks.collection)
* - курсы (content_type='course', content_ref=id курса)
* - экзамен-модули (content_type='exam', content_ref=exam_key)
* Доступ резолвится ДИНАМИЧЕСКИ (без материализации правил content_access):
* - contentAccess.resolve/allowedRefs читают trackGrants/trackRefs;
* - flashcardController.deckAccess/listDecks читают studentCollections.
*
* Добавить направление = добавить запись в TRACKS (+ при необходимости миграцию,
* проставляющую collection нужным колодам). Никаких изменений в резолверах. */
const db = require('../db/db');
/* Реестр треков. collection — ключ коллекции колод (flashcard_decks.collection);
content — что открывает трек, по типам content_access. */
const TRACKS = {
'ct-math': {
title: 'ЦТ/ЦЭ — математика',
label: 'Подготовка к ЦТ',
collection: 'ct-math',
content: { course: ['13'], exam: ['ctmath'] },
},
};
function isTrack(track) { return Object.prototype.hasOwnProperty.call(TRACKS, track); }
/* Множество известных треков, включённых у пользователя.
Inline-prepare + try/catch: до миграции 078 (нет таблицы student_prep) фича
просто не активна (пустой набор), а не падает на загрузке модуля/в тестах. */
function studentTracks(userId) {
try {
const rows = db.prepare('SELECT track FROM student_prep WHERE user_id = ?').all(userId);
return new Set(rows.map(r => r.track).filter(isTrack));
} catch (_) {
return new Set();
}
}
/* Множество collection-ключей, открытых пользователю его треками. */
function studentCollections(userId) {
const out = new Set();
for (const t of studentTracks(userId)) {
if (TRACKS[t].collection) out.add(TRACKS[t].collection);
}
return out;
}
/* Открывает ли какой-либо трек пользователя данный (type, ref)? */
function trackGrants(userId, type, ref) {
const r = String(ref);
for (const t of studentTracks(userId)) {
const refs = TRACKS[t].content && TRACKS[t].content[type];
if (refs && refs.includes(r)) return true;
}
return false;
}
/* content_ref'ы данного типа, открытые треками пользователя (для allowedRefs). */
function trackRefs(userId, type) {
const out = new Set();
for (const t of studentTracks(userId)) {
const refs = TRACKS[t].content && TRACKS[t].content[type];
if (refs) refs.forEach(x => out.add(String(x)));
}
return out;
}
/* Список треков для UI: [{ key, title, label }]. */
function listTracks() {
return Object.entries(TRACKS).map(([key, t]) => ({ key, title: t.title, label: t.label }));
}
module.exports = {
TRACKS, isTrack, studentTracks, studentCollections,
trackGrants, trackRefs, listTracks,
};
+9
View File
@@ -1065,6 +1065,7 @@ window.LS = {
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
prepListTracks, prepMyTracks, prepStudentTracks, prepSetStudent, prepUnsetStudent, prepClassStatus, prepSetClass,
escapeHtml, esc,
parseDate, fmtRelTime, safeHref,
initPage,
@@ -1317,6 +1318,14 @@ async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
async function fcStudySession(deckId){ return req('GET', `/flashcards/decks/${deckId}/study`); }
async function fcReview(cardId, quality) { return req('POST', `/flashcards/cards/${cardId}/review`, { quality }); }
/* ── prep tracks (мастер-флаг «подготовка к ЦТ» и т.п.) ──────────────────── */
async function prepListTracks() { return req('GET', '/prep/tracks'); }
async function prepMyTracks() { return req('GET', '/prep/me'); }
async function prepStudentTracks(uid) { return req('GET', `/prep/student/${uid}`); }
async function prepSetStudent(uid, track) { return req('POST', `/prep/student/${uid}`, { track }); }
async function prepUnsetStudent(uid, track){ return req('DELETE', `/prep/student/${uid}?track=${encodeURIComponent(track)}`); }
async function prepClassStatus(classId, track) { return req('GET', `/prep/class/${classId}?track=${encodeURIComponent(track)}`); }
async function prepSetClass(classId, track, on) { return req('POST', `/prep/class/${classId}`, { track, on: !!on }); }
async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }