diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index aa59253..68f5207 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -1,5 +1,6 @@ const db = require('../db/db'); const { stripTags } = require('../utils/sanitize'); +const prepTracks = require('../services/prepTracks'); /* ── валидация URL картинки ──────────────────────────────────────────────── Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/) — @@ -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) { diff --git a/backend/src/controllers/prepController.js b/backend/src/controllers/prepController.js new file mode 100644 index 0000000..180cd11 --- /dev/null +++ b/backend/src/controllers/prepController.js @@ -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, +}; diff --git a/backend/src/db/migrations/078_prep_tracks.sql b/backend/src/db/migrations/078_prep_tracks.sql new file mode 100644 index 0000000..dedd4fd --- /dev/null +++ b/backend/src/db/migrations/078_prep_tracks.sql @@ -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 'ЦТ ·%'; diff --git a/backend/src/routes/prep.js b/backend/src/routes/prep.js new file mode 100644 index 0000000..3bb899d --- /dev/null +++ b/backend/src/routes/prep.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index be2061f..e90bdca 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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) ── */ diff --git a/backend/src/services/contentAccess.js b/backend/src/services/contentAccess.js index c9e4f21..adaff17 100644 --- a/backend/src/services/contentAccess.js +++ b/backend/src/services/contentAccess.js @@ -10,6 +10,7 @@ * allowedRefs(userId, type) → Set доступных пользователю */ 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; } diff --git a/backend/src/services/prepTracks.js b/backend/src/services/prepTracks.js new file mode 100644 index 0000000..16e7efe --- /dev/null +++ b/backend/src/services/prepTracks.js @@ -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, +}; diff --git a/js/api.js b/js/api.js index 1139ac0..6b64293 100644 --- a/js/api.js +++ b/js/api.js @@ -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); }