Files
Learn_System/backend/src/controllers/flashcardController.js
T
Maxim Dolgolyov 9bd40c5d1c feat(flashcards): общие колоды — учитель назначает колоду классу/ученику
Учитель делится своей колодой с классом или конкретными учениками; карты общие
(одна копия), а прогресс у каждого свой — flashcard_reviews уже keyed по
user_id+card_id, поэтому ученик копит собственные интервалы на тех же картах.

- миграция 075: flashcard_deck_access (deck_id, type class|user, target_id) —
  зеркало folder_access; индексы по target и deck.
- deckAccess(): владелец/админ (canEdit) либо назначенный напрямую/через класс
  (canRead). listDecks отдаёт свои + назначенные (shared/can_edit/owner_name);
  getCards/getStudySession/submitReview пускают по canRead (ученик учится и
  ставит отзыв), правка карт/колоды — только владелец.
- share API (owner + роль teacher/admin): GET /shares, POST /share, DELETE
  /share?type=&target_id=; цель валидируется (свой класс / свой ученик).
- фронт: общие колоды с бейджем учителя, открываются read-only (CSS .readonly
  прячет ручки/удаление/правку, drag и inline-edit выключены), кнопка
  «Поделиться» с модалкой (вкладки Классы/Ученики, тоггл = add/remove share).
- тест flashcards-share 13/13 (шаринг класс/ученик, видимость, изучение+отзыв,
  правка 404, доступ 404, роль-гейт 403, чужой класс 403, снятие доступа).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 13:30:53 +03:00

568 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
/* ── валидация URL картинки ────────────────────────────────────────────────
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
защита от 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,
};