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,
|
||||
};
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 доступ сохранён');
|
||||
});
|
||||
});
|
||||
+178
-8
@@ -375,6 +375,46 @@
|
||||
.study-face { padding: 20px 14px; }
|
||||
.sq-btn { padding: 8px 14px; font-size: .78rem; flex: 1; justify-content: center; }
|
||||
}
|
||||
|
||||
/* ── shared decks (назначенные учителем) ── */
|
||||
.deck-badge.shared { background: rgba(6,214,224,.14); color: #0891b2; max-width: 100%; }
|
||||
.deck-badge.shared .ic { width: 11px; height: 11px; }
|
||||
.deck-card.shared { border-color: rgba(6,214,224,.4); }
|
||||
|
||||
/* ── read-only режим списка карточек (общая колода) ── */
|
||||
#card-list.readonly .card-drag,
|
||||
#card-list.readonly .card-actions,
|
||||
#card-list.readonly .fx-mini,
|
||||
#card-list.readonly .card-img-add,
|
||||
#card-list.readonly .card-img-remove { display: none !important; }
|
||||
#card-list.readonly .card-display { cursor: default; }
|
||||
#card-list.readonly .card-display:hover { background: transparent; }
|
||||
|
||||
/* ── share modal ── */
|
||||
.share-sub { font-size: .82rem; color: var(--text-3); margin: -8px 0 16px; line-height: 1.5; }
|
||||
.share-tabs { display: flex; gap: 8px; margin-bottom: 14px; }
|
||||
.share-tab { flex: 1; padding: 9px; border: 1.5px solid var(--border); border-radius: 10px; background: #fff;
|
||||
cursor: pointer; font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700;
|
||||
color: var(--text-2); transition: .15s; }
|
||||
.share-tab.active { border-color: var(--violet); background: rgba(155,93,229,.08); color: var(--violet); }
|
||||
.share-list { max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 8px;
|
||||
margin-bottom: 6px; padding-right: 4px; }
|
||||
.share-row { display: flex; align-items: center; gap: 12px; padding: 10px 14px;
|
||||
border: 1.5px solid var(--border); border-radius: 12px; background: var(--surface-2);
|
||||
cursor: pointer; transition: .15s; }
|
||||
.share-row:hover { border-color: var(--violet); }
|
||||
.share-row.on { border-color: var(--violet); background: rgba(155,93,229,.07); }
|
||||
.share-row-name { flex: 1; font-size: .88rem; font-weight: 600; color: var(--text); min-width: 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.share-row-sub { font-size: .72rem; color: var(--text-3); font-weight: 500; }
|
||||
.share-toggle { width: 40px; height: 22px; border-radius: 99px; background: var(--border); position: relative;
|
||||
flex-shrink: 0; transition: background .18s; }
|
||||
.share-row.on .share-toggle { background: var(--violet); }
|
||||
.share-toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px;
|
||||
border-radius: 50%; background: #fff; transition: transform .18s; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
|
||||
.share-row.on .share-toggle::after { transform: translateX(18px); }
|
||||
.share-empty { text-align: center; padding: 28px 12px; color: var(--text-3); font-size: .84rem; }
|
||||
.app-layout.dark .share-tab { background: #1A1D27; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -405,7 +445,9 @@
|
||||
</button>
|
||||
<h1 class="fc-title" id="cards-deck-title">Название колоды</h1>
|
||||
<button class="fc-btn fc-btn-ghost" id="cards-ai-btn" onclick="openAiGenModal()">Сгенерировать ИИ</button>
|
||||
<button class="fc-btn fc-btn-ghost" onclick="openBulkModal()">Добавить список</button>
|
||||
<button class="fc-btn fc-btn-ghost" id="cards-bulk-btn" onclick="openBulkModal()">Добавить список</button>
|
||||
<button class="fc-btn fc-btn-ghost" id="cards-share-btn" style="display:none" onclick="openShareModal()">
|
||||
<svg class="ic" viewBox="0 0 24 24" style="width:14px;height:14px;vertical-align:-2px;margin-right:4px"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.6" y1="13.5" x2="15.4" y2="17.5"/><line x1="15.4" y1="6.5" x2="8.6" y2="10.5"/></svg>Поделиться</button>
|
||||
<button class="fc-btn fc-btn-primary" id="cards-study-btn" onclick="startStudy()">Начать изучение</button>
|
||||
</div>
|
||||
<div class="card-search-bar" id="card-search-bar" style="display:none">
|
||||
@@ -415,7 +457,7 @@
|
||||
</div>
|
||||
<div class="card-list" id="card-list"></div>
|
||||
<!-- Add card row -->
|
||||
<div class="card-add-bar" style="margin-bottom:14px">
|
||||
<div class="card-add-bar" id="card-add-row" style="margin-bottom:14px">
|
||||
<input class="card-add-input" id="new-card-front" placeholder="Лицевая сторона (вопрос)…"
|
||||
onkeydown="addCardOnEnter(event)" onpaste="onNewCardPaste(event,'front')" />
|
||||
<input class="card-add-input" id="new-card-back" placeholder="Обратная сторона (ответ)…"
|
||||
@@ -424,7 +466,7 @@
|
||||
<button class="fc-btn fc-btn-primary" onclick="addCard()">+ Добавить</button>
|
||||
</div>
|
||||
<div id="new-card-imgs"></div>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<div id="deck-manage-row" style="display:flex;gap:10px;align-items:center">
|
||||
<button class="fc-btn fc-btn-ghost" onclick="openEditDeckModal()"><svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>️ Редактировать колоду</button>
|
||||
<button class="fc-btn fc-btn-danger" onclick="confirmDeleteDeck()">Удалить колоду</button>
|
||||
</div>
|
||||
@@ -588,6 +630,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Share Deck Modal ── -->
|
||||
<div class="fc-modal" id="modal-share">
|
||||
<div class="fc-modal-bg" onclick="closeModal('modal-share')"></div>
|
||||
<div class="fc-modal-box" style="max-width:520px">
|
||||
<div class="fc-modal-title">Поделиться колодой</div>
|
||||
<p class="share-sub">Назначьте колоду классу или отдельным ученикам. Карточки общие, а прогресс у каждого ученика — свой.</p>
|
||||
<div class="share-tabs">
|
||||
<button class="share-tab active" id="share-tab-class" onclick="shareSetTab('class')">Классы</button>
|
||||
<button class="share-tab" id="share-tab-user" onclick="shareSetTab('user')">Ученики</button>
|
||||
</div>
|
||||
<div class="share-list" id="share-list"></div>
|
||||
<div class="fc-modal-actions">
|
||||
<button class="fc-btn fc-btn-primary" onclick="closeModal('modal-share')">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/imggen.js"></script>
|
||||
@@ -613,11 +672,20 @@ let _editingDeckId = null;
|
||||
let _deckColor = '#9B5DE5';
|
||||
let _cardFilter = '';
|
||||
let _newImg = { front: '', back: '' }; // картинки, прикреплённые к ещё не созданной карточке
|
||||
let _user = null;
|
||||
let _isTeacher = false;
|
||||
let _curDeckReadonly = false; // общая колода (не владелец) — редактирование скрыто
|
||||
// модалка шаринга
|
||||
let _shareData = { shares: [], classes: [], students: [] };
|
||||
let _shareTab = 'class';
|
||||
let _shareSet = new Set(); // ключи 'class:<id>' / 'user:<id>' текущих назначений
|
||||
|
||||
(async () => {
|
||||
/* ── auth ── */
|
||||
const { user } = LS.initPage();
|
||||
if (!user) return;
|
||||
_user = user;
|
||||
_isTeacher = (user.role === 'teacher' || user.role === 'admin');
|
||||
const avatarEl = document.getElementById('nav-avatar');
|
||||
const nameEl = document.getElementById('nav-user');
|
||||
LS.renderNavAvatar(avatarEl, user);
|
||||
@@ -720,7 +788,11 @@ function renderDecks() {
|
||||
const dueHtml = due > 0
|
||||
? `<span class="deck-badge due"><svg class="ic" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>${due} к повторению</span>`
|
||||
: `<span class="deck-badge zero"><svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>Актуально</span>`;
|
||||
return `<div class="deck-card" style="--dc-shadow:${shadow}">
|
||||
// Общая колода (назначена мне учителем): бейдж владельца, без карандаша правки.
|
||||
const sharedHtml = d.shared
|
||||
? `<span class="deck-badge shared" title="Колода от учителя"><svg class="ic" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${esc(d.owner_name || 'учитель')}</span>`
|
||||
: '';
|
||||
return `<div class="deck-card${d.shared ? ' shared' : ''}" style="--dc-shadow:${shadow}">
|
||||
<div class="deck-head" style="background:${color}" onclick="openDeck(${d.id})">
|
||||
<div class="deck-head-letter">${letter}</div>
|
||||
<span class="deck-head-count">${d.card_count} карт.</span>
|
||||
@@ -728,7 +800,7 @@ function renderDecks() {
|
||||
<div class="deck-body" onclick="openDeck(${d.id})">
|
||||
<div class="deck-name">${esc(d.title)}</div>
|
||||
${d.description ? `<div class="deck-desc">${esc(d.description)}</div>` : ''}
|
||||
<div class="deck-meta">${dueHtml}</div>
|
||||
<div class="deck-meta">${dueHtml}${sharedHtml}</div>
|
||||
</div>
|
||||
<div class="deck-actions">
|
||||
<button class="deck-btn-study" onclick="openDeckStudy(${d.id})" ${d.card_count===0?'disabled':''}>
|
||||
@@ -738,8 +810,10 @@ function renderDecks() {
|
||||
? `<svg class="ic" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"/></svg>Изучать`
|
||||
: 'Нет карточек'}
|
||||
</button>
|
||||
<button class="deck-btn-edit" onclick="openDeck(${d.id})">
|
||||
<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
<button class="deck-btn-edit" onclick="openDeck(${d.id})" title="${d.shared ? 'Открыть' : 'Редактировать'}">
|
||||
${d.shared
|
||||
? `<svg class="ic" viewBox="0 0 24 24"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></svg>`
|
||||
: `<svg class="ic" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`}
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -753,11 +827,16 @@ function renderDecks() {
|
||||
async function openDeck(id) {
|
||||
_curDeck = _decks.find(d => d.id === id);
|
||||
if (!_curDeck) return;
|
||||
// Общая колода (назначена мне) — только просмотр и изучение, без правки.
|
||||
_curDeckReadonly = (_curDeck.shared === 1) || (_curDeck.can_edit === 0);
|
||||
document.getElementById('cards-deck-title').textContent = _curDeck.title;
|
||||
applyCardsPermissions();
|
||||
const data = await LS.api(`/api/flashcards/decks/${id}/cards`).catch(()=>({cards:[]}));
|
||||
if (data && data.can_edit === false) _curDeckReadonly = true; // страховка по серверу
|
||||
_cards = data.cards || [];
|
||||
_cardFilter = '';
|
||||
const si = document.getElementById('card-search'); if (si) si.value = '';
|
||||
applyCardsPermissions();
|
||||
renderCardList();
|
||||
document.getElementById('view-decks').style.display = 'none';
|
||||
document.getElementById('view-cards').style.display = 'block';
|
||||
@@ -770,6 +849,19 @@ function openDeckStudy(id) {
|
||||
startStudyForDeck(id);
|
||||
}
|
||||
|
||||
/* Показ/скрытие редактирующих элементов в зависимости от прав на колоду.
|
||||
readonly (общая, не владелец) → прячем добавление/ИИ/список/правку колоды;
|
||||
кнопка «Поделиться» — только владельцу-учителю/админу. */
|
||||
function applyCardsPermissions() {
|
||||
const ed = !_curDeckReadonly;
|
||||
['cards-ai-btn', 'cards-bulk-btn', 'card-add-row', 'deck-manage-row'].forEach(id => {
|
||||
const el = document.getElementById(id); if (el) el.style.display = ed ? '' : 'none';
|
||||
});
|
||||
const imgs = document.getElementById('new-card-imgs'); if (imgs) imgs.style.display = ed ? '' : 'none';
|
||||
const shareBtn = document.getElementById('cards-share-btn');
|
||||
if (shareBtn) shareBtn.style.display = (ed && _isTeacher) ? '' : 'none';
|
||||
}
|
||||
|
||||
function showDecks() {
|
||||
document.getElementById('view-decks').style.display = 'block';
|
||||
document.getElementById('view-cards').style.display = 'none';
|
||||
@@ -856,9 +948,12 @@ function renderCardList() {
|
||||
</div>
|
||||
</div>`).join('');
|
||||
|
||||
// read-only (общая колода) → CSS прячет ручки/удаление/правку, drag не вешаем
|
||||
list.classList.toggle('readonly', _curDeckReadonly);
|
||||
|
||||
// Отрисовать карточки (KaTeX). Правка — по клику (textarea), как в Anki.
|
||||
list.querySelectorAll('.card-display').forEach(fcRenderDisplay);
|
||||
if (!q) bindCardDrag();
|
||||
if (!q && !_curDeckReadonly) bindCardDrag();
|
||||
}
|
||||
|
||||
/* Показать отрисованный текст карточки (или плейсхолдер, если пусто). */
|
||||
@@ -871,6 +966,7 @@ function fcRenderDisplay(disp) {
|
||||
}
|
||||
/* Клик по отрисованной карточке → редактирование (textarea с сырым LaTeX). */
|
||||
function fcStartEdit(disp) {
|
||||
if (_curDeckReadonly) return; // общая колода — только чтение
|
||||
const side = disp.closest('.card-side');
|
||||
const ta = side && side.querySelector('.card-textarea');
|
||||
if (!ta) return;
|
||||
@@ -1748,6 +1844,80 @@ async function confirmDeleteDeck() {
|
||||
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||
|
||||
/* ════ Поделиться колодой (учитель → класс/ученик) ════
|
||||
Карты общие, прогресс у каждого ученика свой. Тоггл сразу шлёт add/remove
|
||||
share (оптимистично, с откатом при ошибке). */
|
||||
async function openShareModal() {
|
||||
if (!_curDeck || !_isTeacher || _curDeckReadonly) return;
|
||||
document.getElementById('modal-share').classList.add('open');
|
||||
document.getElementById('share-list').innerHTML =
|
||||
'<div class="share-empty">Загрузка…</div>';
|
||||
try {
|
||||
const [sh, cls, st] = await Promise.all([
|
||||
LS.api(`/api/flashcards/decks/${_curDeck.id}/shares`).catch(() => ({ shares: [] })),
|
||||
LS.getClasses().catch(() => []),
|
||||
LS.getStudents().catch(() => []),
|
||||
]);
|
||||
_shareData.shares = (sh && sh.shares) || [];
|
||||
_shareData.classes = Array.isArray(cls) ? cls : (cls && cls.classes) || [];
|
||||
_shareData.students = Array.isArray(st) ? st : (st && st.students) || [];
|
||||
_shareSet = new Set(_shareData.shares.map(s => `${s.type}:${s.target_id}`));
|
||||
} catch (e) {
|
||||
_shareData = { shares: [], classes: [], students: [] };
|
||||
_shareSet = new Set();
|
||||
}
|
||||
shareSetTab(_shareTab);
|
||||
}
|
||||
|
||||
function shareSetTab(tab) {
|
||||
_shareTab = tab;
|
||||
document.getElementById('share-tab-class').classList.toggle('active', tab === 'class');
|
||||
document.getElementById('share-tab-user').classList.toggle('active', tab === 'user');
|
||||
renderShareList();
|
||||
}
|
||||
|
||||
function renderShareList() {
|
||||
const box = document.getElementById('share-list');
|
||||
const items = _shareTab === 'class' ? _shareData.classes : _shareData.students;
|
||||
if (!items.length) {
|
||||
box.innerHTML = `<div class="share-empty">${_shareTab === 'class'
|
||||
? 'У вас пока нет классов' : 'У вас пока нет учеников'}</div>`;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = items.map(it => {
|
||||
const on = _shareSet.has(`${_shareTab}:${it.id}`);
|
||||
const sub = _shareTab === 'class'
|
||||
? (it.member_count != null ? `${it.member_count} учеников` : '')
|
||||
: (it.email || '');
|
||||
return `<div class="share-row${on ? ' on' : ''}" data-id="${it.id}" onclick="toggleShare(${it.id}, this)">
|
||||
<div class="share-row-name">${esc(it.name)}${sub ? `<span class="share-row-sub" style="display:block">${esc(sub)}</span>` : ''}</div>
|
||||
<div class="share-toggle"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleShare(id, row) {
|
||||
const key = `${_shareTab}:${id}`;
|
||||
const wasOn = _shareSet.has(key);
|
||||
// оптимистично
|
||||
if (wasOn) { _shareSet.delete(key); row.classList.remove('on'); }
|
||||
else { _shareSet.add(key); row.classList.add('on'); }
|
||||
try {
|
||||
if (wasOn) {
|
||||
await LS.api(`/api/flashcards/decks/${_curDeck.id}/share?type=${_shareTab}&target_id=${id}`, { method: 'DELETE' });
|
||||
} else {
|
||||
await LS.api(`/api/flashcards/decks/${_curDeck.id}/share`, {
|
||||
method: 'POST', body: JSON.stringify({ type: _shareTab, target_id: id })
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// откат
|
||||
if (wasOn) { _shareSet.add(key); row.classList.add('on'); }
|
||||
else { _shareSet.delete(key); row.classList.remove('on'); }
|
||||
LS.toast('Не удалось изменить доступ: ' + (e && e.message || 'ошибка'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user