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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user