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,
};