const db = require('../db/db'); const { stripTags } = require('../utils/sanitize'); /* ── валидация URL картинки ──────────────────────────────────────────────── Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/) — защита от javascript:/data:/внешних URL в src. Всё прочее → пустая строка. */ function safeImg(url) { if (typeof url !== 'string') return ''; const u = url.trim(); return /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(u) ? u : ''; } /* ── Планировщик с learning-steps (Anki-стиль) ───────────────────────────── quality: 0/2 = Снова, 3 = Трудно, 4 = Знаю, 5 = Легко. Карточка живёт в одном из состояний: learning — новая, проходит шаги обучения FC_LEARN_STEPS (минуты); relearning — зрелая, провалена → снова шаги FC_RELEARN_STEPS; review — выпущена, день-интервалы SM-2. На learning/relearning «Снова» возвращает на шаг 0 (минуты!) и карта ПОВТОРНО показывается в той же сессии (re-queue делает клиент по флагу graduated=false). «Знаю» продвигает шаг; пройдя последний — выпуск в review (FC_GRAD_IV дн.), «Легко» выпускает сразу (FC_EASY_IV дн.). На review «Снова» = lapse → relearning (ef −0.2, интервал ×0.5). Зрелые интервалы: Трудно ×1.2, Знаю ×ef, Легко ×ef×1.3. Под-дневные интервалы хранятся в due_at, поэтому interval_days — целые дни последнего выпуска. ВАЖНО: клиентское превью fcPreview() в flashcards.html — зеркало интервальной части этой логики. ─────────────────────────────────────────────────────────────────────── */ const FC_LEARN_STEPS = [1, 10]; // минуты — шаги новой карты const FC_RELEARN_STEPS = [10]; // минуты — шаги после провала зрелой const FC_GRAD_IV = 1; // дней — выпуск через «Знаю» const FC_EASY_IV = 4; // дней — выпуск через «Легко» const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3, FC_MIN_EF = 1.3; /* prev: { state, learning_step, ease_factor, interval_days, repetitions, lapses }. Возвращает следующее расписание + dueInSec (для клиентского re-queue) и graduated (карта в review → из сессии можно убрать). */ function schedule(prev, quality, nowMs) { let state = prev.state || 'new'; let step = prev.learning_step || 0; let ef = prev.ease_factor || 2.5; let iv = prev.interval_days || 0; let reps = prev.repetitions || 0; let lapses = prev.lapses || 0; let dueSec; const learning = (state === 'new' || state === 'learning' || state === 'relearning'); if (learning) { const steps = (state === 'relearning') ? FC_RELEARN_STEPS : FC_LEARN_STEPS; if (quality === 5) { // Легко → выпуск сразу state = 'review'; step = 0; reps = Math.max(reps, 1); iv = FC_EASY_IV; dueSec = iv * 86400; } else if (quality < 3) { // Снова → первый шаг (минуты) if (state === 'new') state = 'learning'; step = 0; dueSec = steps[0] * 60; } else if (quality === 3) { // Трудно → повтор текущего шага if (state === 'new') state = 'learning'; dueSec = steps[Math.min(step, steps.length - 1)] * 60; } else { // Знаю → следующий шаг if (state === 'new') state = 'learning'; step += 1; if (step >= steps.length) { // прошёл все шаги → выпуск const grad = (state === 'relearning') ? Math.max(1, Math.round(iv)) : FC_GRAD_IV; state = 'review'; step = 0; reps = Math.max(reps, 1); iv = grad; dueSec = iv * 86400; } else { dueSec = steps[step] * 60; } } ef = Math.max(FC_MIN_EF, ef); // ease меняем только на review-ответах } else { // state === 'review' if (quality < 3) { // провал → relearning lapses += 1; ef = Math.max(FC_MIN_EF, ef - 0.20); iv = Math.max(1, Math.round(iv * 0.5)); // целевой интервал после переучивания reps = 0; state = 'relearning'; step = 0; dueSec = FC_RELEARN_STEPS[0] * 60; } else { if (quality === 3) iv = Math.max(iv + 1, Math.round(iv * FC_HARD_MULT)); else if (quality === 4) iv = Math.max(iv + 1, Math.round(iv * ef)); else iv = Math.max(iv + 1, Math.round(iv * ef * FC_EASY_BONUS)); reps += 1; ef = Math.max(FC_MIN_EF, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)); dueSec = iv * 86400; } } const dueAt = new Date(nowMs + dueSec * 1000).toISOString(); return { state, learningStep: step, easeFactor: ef, intervalDays: iv, repetitions: reps, lapses, dueAt, dueInSec: dueSec, graduated: state === 'review' }; } /* ── доступ к колоде: владелец / админ / расшарена (user|class) ────────────── { deck, owner, canRead, canEdit }. canEdit — владелец или админ (правка карт/ колоды). canRead — ещё и тот, кому колода назначена напрямую или через класс (учится с личным прогрессом: flashcard_reviews keyed по user_id+card_id). */ function deckAccess(deckId, user) { const deck = db.prepare(`SELECT * FROM flashcard_decks WHERE id = ?`).get(deckId); if (!deck) return { deck: null, owner: false, canRead: false, canEdit: false }; if (deck.user_id === user.id || user.role === 'admin') return { deck, owner: deck.user_id === user.id, canRead: true, canEdit: true }; const shared = db.prepare(` SELECT 1 FROM flashcard_deck_access a WHERE a.deck_id = ? AND ( (a.type = 'user' AND a.target_id = ?) OR (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 }; } /* due_count карты колоды для пользователя: learning/review к повтору (due_at<=now) + новые в пределах дневного лимита (как в getStudySession). 7 binds: uid, deck, uid, deck, deck, deck, uid. */ function deckDueCount(deckId, uid) { return db.prepare(` SELECT ( (SELECT COUNT(*) FROM flashcard_cards c JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? AND r.due_at <= datetime('now')) + MIN( (SELECT COUNT(*) FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? AND r.id IS NULL), MAX(0, (SELECT new_per_day FROM flashcard_decks WHERE id = ?) - (SELECT COUNT(*) FROM flashcard_reviews r JOIN flashcard_cards c ON c.id = r.card_id WHERE c.deck_id = ? AND r.user_id = ? AND date(r.created_at) = date('now'))) ) ) AS n `).get(uid, deckId, uid, deckId, deckId, deckId, uid).n; } /* ── GET /api/flashcards/decks ─────────────────────────────────────────────── Свои колоды + назначенные мне (через class/user). shared/can_edit/owner_name — для UI: общие открываются только на чтение и изучение. */ function listDecks(req, res) { const uid = req.user.id; const decks = db.prepare(` SELECT d.*, u.name AS owner_name, CASE WHEN d.user_id = ? THEN 1 ELSE 0 END AS can_edit, CASE WHEN d.user_id = ? THEN 0 ELSE 1 END AS shared FROM flashcard_decks d JOIN users u ON u.id = d.user_id WHERE d.user_id = ? OR EXISTS (SELECT 1 FROM flashcard_deck_access a WHERE a.deck_id = d.id AND a.type = 'user' AND a.target_id = ?) 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') ORDER BY shared ASC, d.created_at DESC `).all(uid, uid, uid, uid, uid); const cardStmt = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_cards WHERE deck_id = ?`); for (const d of decks) { d.card_count = cardStmt.get(d.id).n; d.due_count = deckDueCount(d.id, uid); } res.json({ decks }); } /* ── POST /api/flashcards/decks ────────────────────────────────────────── */ function createDeck(req, res) { const uid = req.user.id; const { title, description = '', color = '#9B5DE5' } = req.body; if (!title?.trim()) return res.status(400).json({ error: 'Title required' }); const r = db.prepare( `INSERT INTO flashcard_decks (user_id, title, description, color) VALUES (?,?,?,?)` ).run(uid, title.trim(), description, color); res.json({ id: r.lastInsertRowid, title: title.trim(), description, color, card_count: 0, due_count: 0 }); } /* ── PUT /api/flashcards/decks/:id ─────────────────────────────────────── */ function updateDeck(req, res) { const uid = req.user.id; const { title, description, color } = req.body; const deck = db.prepare(`SELECT * FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); db.prepare(`UPDATE flashcard_decks SET title=?, description=?, color=? WHERE id=?`) .run(title ?? deck.title, description ?? deck.description, color ?? deck.color, deck.id); res.json({ ok: true }); } /* ── DELETE /api/flashcards/decks/:id ──────────────────────────────────── */ function deleteDeck(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); db.prepare(`DELETE FROM flashcard_decks WHERE id = ?`).run(deck.id); res.json({ ok: true }); } /* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────────── Доступно владельцу и тем, кому колода назначена (общая открывается read-only — can_edit подсказывает фронту прятать редактирование). */ function getCards(req, res) { const uid = req.user.id; const acc = deckAccess(req.params.id, req.user); if (!acc.canRead) return res.status(404).json({ error: 'Not found' }); const cards = db.prepare(` SELECT c.*, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? ORDER BY c.order_idx, c.id `).all(uid, acc.deck.id); res.json({ cards, can_edit: acc.canEdit }); } /* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */ function addCard(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); const front = stripTags((req.body.front || '').slice(0, 5000)); const back = stripTags((req.body.back || '').slice(0, 5000)); const front_image = safeImg(req.body.front_image); const back_image = safeImg(req.body.back_image); const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) .get(deck.id)?.m ?? -1; const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`) .run(deck.id, front, back, front_image, back_image, maxIdx + 1); res.json({ id: r.lastInsertRowid, deck_id: deck.id, front, back, front_image, back_image, order_idx: maxIdx + 1 }); } /* ── POST /api/flashcards/decks/:id/cards/bulk ──────────────────────────── */ function addCardsBulk(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); const { cards } = req.body; if (!Array.isArray(cards) || !cards.length) return res.status(400).json({ error: 'cards[] required' }); const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) .get(deck.id)?.m ?? -1; const stmt = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`); const inserted = []; const ins = db.transaction(() => { cards.forEach((c, i) => { const front = stripTags((c.front || '').slice(0, 5000)); const back = stripTags((c.back || '').slice(0, 5000)); const fImg = safeImg(c.front_image); const bImg = safeImg(c.back_image); const r = stmt.run(deck.id, front, back, fImg, bImg, maxIdx + 1 + i); inserted.push({ id: r.lastInsertRowid, front, back, front_image: fImg, back_image: bImg }); }); }); ins(); res.json({ inserted }); } /* ── PUT /api/flashcards/decks/:id/reorder ───────────────────────────────── body: { order: [cardId, …] } — переписывает order_idx по позиции в массиве. Принимаются только карточки, реально принадлежащие колоде владельца. */ function reorderCards(req, res) { const uid = req.user.id; const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(req.params.id, uid); if (!deck) return res.status(404).json({ error: 'Not found' }); const { order } = req.body; if (!Array.isArray(order) || !order.length) return res.status(400).json({ error: 'order[] required' }); const owned = new Set( db.prepare(`SELECT id FROM flashcard_cards WHERE deck_id = ?`).all(deck.id).map(r => r.id) ); const stmt = db.prepare(`UPDATE flashcard_cards SET order_idx = ? WHERE id = ? AND deck_id = ?`); const run = db.transaction(() => { let idx = 0; order.forEach(id => { if (owned.has(Number(id))) stmt.run(idx++, Number(id), deck.id); }); }); run(); res.json({ ok: true }); } /* ── PUT /api/flashcards/cards/:id ─────────────────────────────────────── */ function updateCard(req, res) { const uid = req.user.id; const card = db.prepare(` SELECT c.* FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE c.id = ? AND d.user_id = ? `).get(req.params.id, uid); if (!card) return res.status(404).json({ error: 'Not found' }); const { front, back } = req.body; // Картинки трогаем только если поле реально пришло (пустая строка = очистить). const frontImg = ('front_image' in req.body) ? safeImg(req.body.front_image) : card.front_image; const backImg = ('back_image' in req.body) ? safeImg(req.body.back_image) : card.back_image; db.prepare(`UPDATE flashcard_cards SET front=?, back=?, front_image=?, back_image=? WHERE id=?`) .run(front ?? card.front, back ?? card.back, frontImg, backImg, card.id); res.json({ ok: true, front_image: frontImg, back_image: backImg }); } /* ── DELETE /api/flashcards/cards/:id ──────────────────────────────────── */ function deleteCard(req, res) { const uid = req.user.id; const card = db.prepare(` SELECT c.id FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE c.id = ? AND d.user_id = ? `).get(req.params.id, uid); if (!card) return res.status(404).json({ error: 'Not found' }); db.prepare(`DELETE FROM flashcard_cards WHERE id = ?`).run(card.id); res.json({ ok: true }); } /* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */ function getStudySession(req, res) { const uid = req.user.id; const acc = deckAccess(req.params.id, req.user); // владелец ИЛИ кому назначена if (!acc.canRead) return res.status(404).json({ error: 'Not found' }); const deck = acc.deck; // 1) Карты к повторению (есть строка отзыва, due_at<=now): learning — по минутам, // review — по дням. Отдаём по возрастанию due_at (срочные learning впереди). const dueCards = db.prepare(` SELECT c.id, c.front, c.back, c.front_image, c.back_image, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed, r.state, r.learning_step, 1 AS seen FROM flashcard_cards c JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? AND r.due_at <= datetime('now') ORDER BY r.due_at ASC LIMIT 100 `).all(uid, deck.id); // 2) Новые карты (без отзыва), но не больше дневного лимита за вычетом уже // введённых сегодня — защита от перегруза на большой колоде. const newToday = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_reviews r JOIN flashcard_cards c ON c.id = r.card_id WHERE c.deck_id = ? AND r.user_id = ? AND date(r.created_at) = date('now') `).get(deck.id, uid).n; const newBudget = Math.max(0, (deck.new_per_day || 20) - newToday); const newCards = newBudget > 0 ? db.prepare(` SELECT c.id, c.front, c.back, c.front_image, c.back_image, 2.5 AS ease_factor, 0 AS interval_days, 0 AS repetitions, datetime('now') AS due_at, NULL AS last_reviewed, 'new' AS state, 0 AS learning_step, 0 AS seen FROM flashcard_cards c LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE c.deck_id = ? AND r.id IS NULL ORDER BY c.order_idx, c.id LIMIT ? `).all(uid, deck.id, newBudget) : []; const cards = dueCards.concat(newCards); res.json({ cards, total_due: cards.length }); } /* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */ function submitReview(req, res) { const uid = req.user.id; const { quality } = req.body; // 0..5 if (quality === undefined || quality < 0 || quality > 5) return res.status(400).json({ error: 'quality 0-5 required' }); // Отзыв может ставить владелец И ученик, которому колода назначена (свой прогресс). const card = db.prepare(`SELECT id, deck_id FROM flashcard_cards WHERE id = ?`).get(req.params.id); if (!card) return res.status(404).json({ error: 'Not found' }); if (!deckAccess(card.deck_id, req.user).canRead) return res.status(404).json({ error: 'Not found' }); const existing = db.prepare( `SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?` ).get(uid, card.id); const prev = existing || { state: 'new', learning_step: 0, ease_factor: 2.5, interval_days: 0, repetitions: 0, lapses: 0 }; const next = schedule(prev, quality, Date.now()); if (existing) { db.prepare(` UPDATE flashcard_reviews SET state=?, learning_step=?, ease_factor=?, interval_days=?, repetitions=?, lapses=?, due_at=?, last_reviewed=datetime('now') WHERE user_id=? AND card_id=? `).run(next.state, next.learningStep, next.easeFactor, next.intervalDays, next.repetitions, next.lapses, next.dueAt, uid, card.id); } else { db.prepare(` INSERT INTO flashcard_reviews (user_id, card_id, state, learning_step, ease_factor, interval_days, repetitions, lapses, due_at, last_reviewed, created_at) VALUES (?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now')) `).run(uid, card.id, next.state, next.learningStep, next.easeFactor, next.intervalDays, next.repetitions, next.lapses, next.dueAt); } res.json({ ok: true, graduated: next.graduated, // true → карта в review (из сессии можно убрать) due_in_sec: next.dueInSec, next_review: next.dueAt, interval_days: next.intervalDays, next: { state: next.state, learning_step: next.learningStep, ease_factor: next.easeFactor, interval_days: next.intervalDays, repetitions: next.repetitions, }, }); } /* ── GET /api/flashcards/stats ─────────────────────────────────────────── */ function getStats(req, res) { const uid = req.user.id; const decks_count = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_decks WHERE user_id=?`).get(uid).n; const cards_count = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id=? `).get(uid).n; const due_count = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? WHERE d.user_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now')) `).get(uid, uid).n; const reviewed_today = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_reviews WHERE user_id = ? AND date(last_reviewed) = date('now') `).get(uid).n; res.json({ decks_count, cards_count, due_count, reviewed_today }); } /* ── POST /api/flashcards/quick — быстрое добавление из любой точки ────── Кладёт карточку в указанную колоду (deckId) либо в личную колоду «Быстрые карточки» (создаётся при первом обращении). */ const QUICK_DECK_TITLE = 'Быстрые карточки'; function quickAdd(req, res) { const uid = req.user.id; const front = stripTags((req.body.front || '').slice(0, 5000)).trim(); const back = stripTags((req.body.back || '').slice(0, 5000)).trim(); const front_image = safeImg(req.body.front_image); const back_image = safeImg(req.body.back_image); if (!front && !front_image) return res.status(400).json({ error: 'Заполни лицевую сторону (текст или картинку)' }); let deck = null; const deckId = Number(req.body.deckId) || 0; if (deckId) { deck = db.prepare(`SELECT id, title, color FROM flashcard_decks WHERE id = ? AND user_id = ?`) .get(deckId, uid); } if (!deck) { deck = db.prepare(`SELECT id, title, color FROM flashcard_decks WHERE user_id = ? AND title = ? ORDER BY id LIMIT 1`) .get(uid, QUICK_DECK_TITLE); if (!deck) { const r = db.prepare( `INSERT INTO flashcard_decks (user_id, title, description, color) VALUES (?,?,?,?)` ).run(uid, QUICK_DECK_TITLE, 'Карточки, добавленные на лету', '#9B5DE5'); deck = { id: r.lastInsertRowid, title: QUICK_DECK_TITLE, color: '#9B5DE5', created: true }; } } const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`) .get(deck.id)?.m ?? -1; const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, front_image, back_image, order_idx) VALUES (?,?,?,?,?,?)`) .run(deck.id, front, back, front_image, back_image, maxIdx + 1); res.json({ id: r.lastInsertRowid, deck_id: deck.id, deck_title: deck.title, deck_color: deck.color, front, back, front_image, back_image }); } /* ── GET /api/flashcards/random — случайная карточка из всего пула ─────── Для дашборд-виджета «повтори карточку». */ function getRandom(req, res) { const uid = req.user.id; const total = db.prepare(` SELECT COUNT(*) AS n FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id = ? `).get(uid).n; if (!total) return res.json({ card: null, total: 0 }); const card = db.prepare(` SELECT c.id, c.front, c.back, c.front_image, c.back_image, c.deck_id, d.title AS deck_title, d.color AS deck_color FROM flashcard_cards c JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id = ? ORDER BY RANDOM() LIMIT 1 `).get(uid); res.json({ card, total }); } /* ── POST /api/flashcards/upload — загрузка картинки для карточки ────────── multipart (поле file). Возвращает { url } для сохранения в front_image / back_image. Сам файл уже на диске (multer); БД здесь не трогаем. */ function uploadImage(req, res) { if (!req.file) return res.status(400).json({ error: 'Файл не получен (только изображения до 5 МБ)' }); res.json({ url: `/uploads/flashcards/${req.file.filename}` }); } /* ══ Шаринг колоды учителем (назначение классу/ученику) ══════════════════════ Карты общие (одна копия), прогресс у каждого свой. Управление — только владелец/админ (canEdit). Цель валидируется: класс/ученик должен принадлежать учителю (или роль admin) — нельзя расшарить «в чужой класс». */ /* ── GET /api/flashcards/decks/:id/shares ── */ function listShares(req, res) { const acc = deckAccess(req.params.id, req.user); if (!acc.deck) return res.status(404).json({ error: 'Not found' }); if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' }); const shares = db.prepare(` SELECT a.id, a.type, a.target_id, a.created_at, CASE WHEN a.type = 'class' THEN (SELECT name FROM classes WHERE id = a.target_id) ELSE (SELECT name FROM users WHERE id = a.target_id) END AS target_name FROM flashcard_deck_access a WHERE a.deck_id = ? ORDER BY a.type, target_name `).all(acc.deck.id); res.json({ shares }); } /* проверка: класс/ученик принадлежит учителю (admin — без ограничений). */ function ownsTarget(user, type, targetId) { if (user.role === 'admin') return true; if (type === 'class') return !!db.prepare(`SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?`).get(targetId, user.id); // type === 'user' — ученик в одном из классов учителя ИЛИ его персональный ученик 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(targetId, user.id, targetId, user.id); } /* ── POST /api/flashcards/decks/:id/share { type, target_id } ── */ function addShare(req, res) { const acc = deckAccess(req.params.id, req.user); if (!acc.deck) return res.status(404).json({ error: 'Not found' }); if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' }); const type = req.body.type; const targetId = Number(req.body.target_id) || 0; if (!['class', 'user'].includes(type) || !targetId) return res.status(400).json({ error: 'type (class|user) и target_id обязательны' }); if (!ownsTarget(req.user, type, targetId)) return res.status(403).json({ error: type === 'class' ? 'Это не ваш класс' : 'Этот ученик не в ваших классах' }); db.prepare(`INSERT OR IGNORE INTO flashcard_deck_access (deck_id, type, target_id) VALUES (?,?,?)`) .run(acc.deck.id, type, targetId); res.json({ ok: true }); } /* ── DELETE /api/flashcards/decks/:id/share?type=&target_id= ── */ function removeShare(req, res) { const acc = deckAccess(req.params.id, req.user); if (!acc.deck) return res.status(404).json({ error: 'Not found' }); if (!acc.canEdit) return res.status(403).json({ error: 'Forbidden' }); const type = req.query.type; const targetId = Number(req.query.target_id) || 0; if (!['class', 'user'].includes(type) || !targetId) return res.status(400).json({ error: 'type и target_id обязательны' }); db.prepare(`DELETE FROM flashcard_deck_access WHERE deck_id = ? AND type = ? AND target_id = ?`) .run(acc.deck.id, type, targetId); res.json({ ok: true }); } module.exports = { listDecks, createDeck, updateDeck, deleteDeck, getCards, addCard, addCardsBulk, updateCard, deleteCard, reorderCards, getStudySession, submitReview, getStats, quickAdd, getRandom, uploadImage, listShares, addShare, removeShare, };