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>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:30:53 +03:00
parent f26b522207
commit 9bd40c5d1c
5 changed files with 484 additions and 44 deletions
+141 -35
View File
@@ -94,32 +94,72 @@ function schedule(prev, quality, nowMs) {
lapses, dueAt, dueInSec: dueSec, graduated: state === 'review' };
}
/* ── GET /api/flashcards/decks ─────────────────────────────────────────── */
/* ── доступ к колоде: владелец / админ / расшарена (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;
// due_count = зрелые/learning к повтору (due_at<=now) + новые, но не больше
// дневного лимита new_per_day за вычетом уже введённых сегодня — чтобы бейдж
// отражал реальный размер сессии, а не весь бэклог новых карт.
const decks = db.prepare(`
SELECT d.*,
(SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count,
(
(SELECT COUNT(*) FROM flashcard_cards c
JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = d.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 = d.id AND r.id IS NULL),
MAX(0, d.new_per_day - (SELECT COUNT(*) FROM flashcard_reviews r
JOIN flashcard_cards c ON c.id = r.card_id
WHERE c.deck_id = d.id AND r.user_id = ? AND date(r.created_at) = date('now')))
)
) AS due_count
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 = ?
ORDER BY d.created_at DESC
`).all(uid, uid, uid, uid);
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 });
}
@@ -156,20 +196,21 @@ function deleteDeck(req, res) {
res.json({ ok: true });
}
/* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────── */
/* ── GET /api/flashcards/decks/:id/cards ─────────────────────────────────────
Доступно владельцу и тем, кому колода назначена (общая открывается read-only —
can_edit подсказывает фронту прятать редактирование). */
function getCards(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 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, deck.id);
res.json({ cards });
`).all(uid, acc.deck.id);
res.json({ cards, can_edit: acc.canEdit });
}
/* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */
@@ -273,9 +314,9 @@ function deleteCard(req, res) {
/* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */
function getStudySession(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id, new_per_day FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
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 впереди).
@@ -321,12 +362,10 @@ function submitReview(req, res) {
if (quality === undefined || quality < 0 || quality > 5)
return res.status(400).json({ error: 'quality 0-5 required' });
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);
// Отзыв может ставить владелец И ученик, которому колода назначена (свой прогресс).
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 = ?`
@@ -453,9 +492,76 @@ function uploadImage(req, res) {
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,
};