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,
};
@@ -0,0 +1,20 @@
-- 075_flashcard_deck_access.sql
-- Общие колоды: учитель назначает свою колоду классу или конкретному ученику.
-- Карты остаются общими (одна копия), а прогресс у каждого свой — flashcard_reviews
-- уже keyed по UNIQUE(user_id, card_id), поэтому ученик копит собственные интервалы
-- на тех же картах. Колода остаётся во владении учителя (правка — только владелец).
--
-- Структура — зеркало folder_access (000_baseline): type='class' → target_id=class_id,
-- type='user' → target_id=user_id. Резолв класса ученика — через class_members.
CREATE TABLE flashcard_deck_access (
id INTEGER PRIMARY KEY AUTOINCREMENT,
deck_id INTEGER NOT NULL REFERENCES flashcard_decks(id) ON DELETE CASCADE,
type TEXT NOT NULL CHECK (type IN ('class', 'user')),
target_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (deck_id, type, target_id)
);
CREATE INDEX IF NOT EXISTS idx_fc_deck_access_target ON flashcard_deck_access(type, target_id);
CREATE INDEX IF NOT EXISTS idx_fc_deck_access_deck ON flashcard_deck_access(deck_id);
+5 -1
View File
@@ -5,7 +5,7 @@ const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const fc = require('../controllers/flashcardController');
const { authMiddleware } = require('../middleware/auth');
const { authMiddleware, requireRole } = require('../middleware/auth');
const { requireOwnership } = require('../middleware/ownership');
/* ── multer для картинок карточек ───────────────────────────────────────
@@ -43,6 +43,10 @@ router.get ('/decks/:id/cards', fc.getCards);
router.post ('/decks/:id/cards', fc.addCard);
router.post ('/decks/:id/cards/bulk', fc.addCardsBulk);
router.put ('/decks/:id/reorder', requireOwnership({ table: 'flashcard_decks', ownerField: 'user_id' }), fc.reorderCards);
// Шаринг колоды (назначение классу/ученику) — только владелец/админ (проверка в хендлере).
router.get ('/decks/:id/shares', fc.listShares);
router.post ('/decks/:id/share', requireRole('teacher','admin'), fc.addShare);
router.delete('/decks/:id/share', requireRole('teacher','admin'), fc.removeShare);
router.get ('/decks/:id/study', fc.getStudySession);
router.put ('/cards/:id', fc.updateCard);
router.delete('/cards/:id', fc.deleteCard);
+140
View File
@@ -0,0 +1,140 @@
'use strict';
/**
* Integration tests: общие колоды флешкарт (учитель → класс/ученик).
* Covers: шаринг классу/ученику, видимость у назначенного (own+published),
* изучение и отзыв с личным прогрессом на общих картах, запрет правки чужой
* колоды (404), запрет доступа неназначенному (404), ролевой гейт share (403),
* запрет шарить в чужой класс (403), снятие доступа.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { app, db, inject, getToken, cleanup } = require('./setup');
app.use('/api/flashcards', require('../src/routes/flashcards'));
after(() => cleanup());
let _cc = 0;
function mkClass(teacherId) {
const code = 'T' + (++_cc) + Math.floor(performance.now() % 100000);
const r = db.prepare(`INSERT INTO classes (name, teacher_id, invite_code) VALUES (?,?,?)`)
.run('Класс ' + _cc, teacherId, code);
return r.lastInsertRowid;
}
function enroll(classId, userId) {
db.prepare(`INSERT OR IGNORE INTO class_members (class_id, user_id) VALUES (?,?)`).run(classId, userId);
}
describe('flashcards — общие колоды (sharing)', () => {
let teacher, otherTeacher, stuIn, stuOut;
let classId, deckId, cardId;
before(async () => {
teacher = await getToken('teacher');
otherTeacher = await getToken('teacher');
stuIn = await getToken('student');
stuOut = await getToken('student');
classId = mkClass(teacher.userId);
enroll(classId, stuIn.userId);
// учитель создаёт колоду с карточкой
const d = await inject('POST', '/api/flashcards/decks', { title: 'Биология' }, teacher.token);
deckId = d.body.id;
const c = await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front: 'Клетка?', back: 'Единица жизни' }, teacher.token);
cardId = c.body.id;
});
it('student cannot share (роль-гейт 403)', async () => {
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: classId }, stuIn.token);
assert.equal(r.status, 403, `got ${r.status}`);
});
it('teacher cannot share to a class he does not own (403)', async () => {
const foreignClass = mkClass(otherTeacher.userId);
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: foreignClass }, teacher.token);
assert.equal(r.status, 403, `got ${r.status}: ${JSON.stringify(r.body)}`);
});
it('not-shared: ученик не в классе не видит колоду и не имеет доступа', async () => {
const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token);
assert.ok(!list.body.decks.some(d => d.id === deckId), 'пока не расшарена — не видна');
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/cards`, null, stuIn.token)).status, 404);
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token)).status, 404);
});
it('teacher shares deck to class', async () => {
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'class', target_id: classId }, teacher.token);
assert.equal(r.status, 200, `got ${r.status}: ${JSON.stringify(r.body)}`);
});
it('назначенный ученик видит колоду как shared (read-only)', async () => {
const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token);
const d = list.body.decks.find(x => x.id === deckId);
assert.ok(d, 'колода видна ученику');
assert.equal(d.shared, 1);
assert.equal(d.can_edit, 0);
assert.equal(d.owner_name, teacher.name);
});
it('ученик НЕ в классе по-прежнему не видит', async () => {
const list = await inject('GET', '/api/flashcards/decks', null, stuOut.token);
assert.ok(!list.body.decks.some(d => d.id === deckId));
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuOut.token)).status, 404);
});
it('ученик может изучать и ставить отзыв (свой прогресс)', async () => {
const s = await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token);
assert.equal(s.status, 200);
assert.ok(s.body.cards.some(c => c.id === cardId), 'карта в сессии ученика');
const rev = await inject('POST', `/api/flashcards/cards/${cardId}/review`, { quality: 4 }, stuIn.token);
assert.equal(rev.status, 200, `review: ${rev.status}`);
// прогресс ученика — отдельная строка от учителя
const row = db.prepare('SELECT COUNT(*) AS n FROM flashcard_reviews WHERE card_id=? AND user_id=?').get(cardId, stuIn.userId);
assert.equal(row.n, 1, 'у ученика свой отзыв');
});
it('ученик НЕ может править общую колоду (404)', async () => {
assert.equal((await inject('POST', `/api/flashcards/decks/${deckId}/cards`, { front: 'x', back: 'y' }, stuIn.token)).status, 404);
assert.equal((await inject('PUT', `/api/flashcards/decks/${deckId}`, { title: 'Взлом' }, stuIn.token)).status, 404);
assert.equal((await inject('PUT', `/api/flashcards/cards/${cardId}`, { front: 'Взлом' }, stuIn.token)).status, 404);
assert.equal((await inject('DELETE', `/api/flashcards/cards/${cardId}`, null, stuIn.token)).status, 404);
});
it('ученик не может расшаривать чужую колоду (роль/доступ)', async () => {
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'user', target_id: stuOut.userId }, stuIn.token);
assert.equal(r.status, 403);
});
it('teacher shares to a specific user (вне класса)', async () => {
// делаем stuOut персональным учеником, чтобы прошла валидация цели
db.prepare(`INSERT OR IGNORE INTO teacher_students (teacher_id, student_id) VALUES (?,?)`)
.run(teacher.userId, stuOut.userId);
const r = await inject('POST', `/api/flashcards/decks/${deckId}/share`, { type: 'user', target_id: stuOut.userId }, teacher.token);
assert.equal(r.status, 200, `got ${r.status}: ${JSON.stringify(r.body)}`);
const list = await inject('GET', '/api/flashcards/decks', null, stuOut.token);
assert.ok(list.body.decks.some(d => d.id === deckId), 'stuOut теперь видит');
});
it('teacher lists shares (2: class + user)', async () => {
const r = await inject('GET', `/api/flashcards/decks/${deckId}/shares`, null, teacher.token);
assert.equal(r.status, 200);
assert.equal(r.body.shares.length, 2);
assert.ok(r.body.shares.some(s => s.type === 'class' && s.target_id === classId));
assert.ok(r.body.shares.some(s => s.type === 'user' && s.target_id === stuOut.userId));
});
it('ученик не может смотреть список share (403)', async () => {
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/shares`, null, stuIn.token)).status, 403);
});
it('teacher unshares class → ученик в классе теряет доступ', async () => {
const r = await inject('DELETE', `/api/flashcards/decks/${deckId}/share?type=class&target_id=${classId}`, null, teacher.token);
assert.equal(r.status, 200);
const list = await inject('GET', '/api/flashcards/decks', null, stuIn.token);
assert.ok(!list.body.decks.some(d => d.id === deckId), 'после снятия — не видит');
assert.equal((await inject('GET', `/api/flashcards/decks/${deckId}/study`, null, stuIn.token)).status, 404);
// прямой share для stuOut остаётся
const listOut = await inject('GET', '/api/flashcards/decks', null, stuOut.token);
assert.ok(listOut.body.decks.some(d => d.id === deckId), 'stuOut доступ сохранён');
});
});