9bd40c5d1c
Учитель делится своей колодой с классом или конкретными учениками; карты общие (одна копия), а прогресс у каждого свой — 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>
568 lines
30 KiB
JavaScript
568 lines
30 KiB
JavaScript
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,
|
||
};
|