feat(materials): Фаза 6b — раздатка материала ученикам/классу

- POST /api/materials/:id/share {classId|userId} (teacher/admin): создаёт независимую КОПИЮ
  материала каждому ученику класса (source_title «Раздатка: <учитель>») + уведомление через SSE.
- /my-materials: кнопка «Раздать» на карточках (видна учителю/админу) → выбор класса.
- Хелпер LS.shareMaterial. На этом план «Мои материалы» (6 фаз) завершён.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 12:26:46 +03:00
parent e793b4ec09
commit f7357adf1e
4 changed files with 77 additions and 6 deletions
@@ -3,6 +3,7 @@
* A user keeps copies of items saved from live lessons; the copies are
* independent of the session lifecycle. */
const db = require('../db/db');
const { emit } = require('../sse');
const KINDS = ['board', 'note', 'link', 'image'];
@@ -129,4 +130,44 @@ function deleteCollection(req, res) {
res.json({ ok: true });
}
module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection };
/* POST /api/materials/:id/share — teacher hands a material out to a class or
a student. Each recipient gets an independent COPY (survives later edits/
deletes by the teacher). Body: { classId } | { userId }. */
function share(req, res) {
const mat = db.prepare('SELECT user_id, kind, title, body, url FROM student_materials WHERE id = ?').get(req.params.id);
if (!mat) return res.status(404).json({ error: 'not found' });
if (mat.user_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
const b = req.body || {};
let recipients = [];
if (b.classId) {
const cls = db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?').get(b.classId);
if (!cls) return res.status(404).json({ error: 'class not found' });
if (cls.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'not your class' });
recipients = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(b.classId).map(r => r.user_id);
} else if (b.userId) {
const uid = Number(b.userId);
if (Number.isFinite(uid)) recipients = [uid];
} else {
return res.status(400).json({ error: 'classId or userId required' });
}
const teacherName = (db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id) || {}).name || 'Учитель';
const srcTitle = 'Раздатка: ' + teacherName;
const ins = db.prepare(`INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title) VALUES (?,?,?,?,?,NULL,?)`);
let sent = 0;
db.transaction(() => {
for (const uid of recipients) {
if (!uid || uid === req.user.id) continue;
ins.run(uid, mat.kind, mat.title, mat.body, mat.url, srcTitle);
try {
emit(uid, { type: 'notification', notif_type: 'material_shared',
message: `Новый материал от ${teacherName}: «${mat.title || 'материал'}»`, link: '/my-materials' });
} catch (e) { /* ignore notify failure */ }
sent++;
}
})();
res.json({ ok: true, sent });
}
module.exports = { list, create, update, remove, createCollection, updateCollection, deleteCollection, share };
+4 -1
View File
@@ -1,11 +1,14 @@
'use strict';
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const { authMiddleware, requireRole } = require('../middleware/auth');
const c = require('../controllers/studentMaterialsController');
router.use(authMiddleware);
// Teacher hands a material out to a class/student (copies to recipients)
router.post('/:id/share', requireRole('teacher', 'admin'), c.share);
router.get('/', c.list);
router.post('/', c.create);
+29 -3
View File
@@ -79,7 +79,8 @@
<script src="/js/svg-sanitize.js"></script>
<script src="/js/svg-draw.js"></script>
<script>
LS.initPage();
const _ip = LS.initPage() || {};
const _canShare = !!(_ip.isTeacher || _ip.isAdmin);
function esc(s) { return LS.escapeHtml(String(s || '')); }
function fmtDate(s) {
@@ -110,6 +111,8 @@
? `<button class="mm-btn" onclick="annotate(${m.id})" title="Аннотировать (рисовать поверх)"><i data-lucide="pencil-ruler"></i></button>` : '';
const fc = (m.kind === 'note')
? `<button class="mm-btn" onclick="toFlashcard(${m.id})" title="В флешкарты"><i data-lucide="copy"></i></button>` : '';
const sh = _canShare
? `<button class="mm-btn" onclick="openShareModal(${m.id})" title="Раздать ученикам"><i data-lucide="send"></i></button>` : '';
const mv = moveSelect(m);
if (m.kind === 'board' || m.kind === 'image') {
return `<div class="mm-card">
@@ -122,7 +125,7 @@
${mv}
<a class="mm-btn" href="${esc(m.url)}" target="_blank" rel="noopener" title="Открыть"><i data-lucide="external-link"></i></a>
<a class="mm-btn" href="${esc(m.url)}" download title="Скачать"><i data-lucide="download"></i></a>
${ann}${edit}${del}
${ann}${sh}${edit}${del}
</div>
</div>
</div>`;
@@ -134,7 +137,7 @@
<div class="mm-card-body">
<div class="mm-card-title">${esc(m.title || kind)}</div>
<div class="mm-card-meta">${meta}</div>
<div class="mm-card-actions">${mv}${fc}${edit}${del}</div>
<div class="mm-card-actions">${mv}${fc}${sh}${edit}${del}</div>
</div>
</div>`;
}
@@ -385,6 +388,29 @@
}
window.toFlashcard = toFlashcard;
/* ── Раздатка: учитель → класс (копия ученикам) ── */
async function openShareModal(id) {
let classes = [];
try { classes = await LS.getClasses(); } catch (e) {}
if (!Array.isArray(classes) || !classes.length) { LS.toast('Нет классов для раздачи', 'warn'); return; }
const opts = classes.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('');
const content = `<div style="display:flex;flex-direction:column;gap:8px">
<label style="font-size:.8rem;color:var(--text-3)">Класс</label>
<select id="sh-class" style="${FLD}">${opts}</select>
<div style="font-size:.78rem;color:var(--text-3)">Копия материала появится у всех учеников класса (с уведомлением).</div>
</div>`;
const m = LS.modal({ title: 'Раздать материал', content, size: 'sm', actions: [
{ label: 'Отмена', onClick: () => m.close() },
{ label: 'Раздать', primary: true, onClick: async () => {
try {
const r = await LS.shareMaterial(id, { classId: Number(m.body.querySelector('#sh-class').value) });
m.close(); LS.toast('Отправлено ученикам: ' + (r.sent || 0), 'success');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
} },
] });
}
window.openShareModal = openShareModal;
load();
</script>
</body>
+2 -1
View File
@@ -1048,7 +1048,7 @@ window.LS = {
crJoin, crLeave, crSendChat, crGetChat, crGetAttendance, crSignal, crGetOnlineStudents, crGetMySession,
crGetMyHistory, crGetClassHistory, crGetSessionSummary, crExportChatUrl, crGetAllNotes, crDeleteHistory,
crAdminGetAllHistory, crAdminGetTeachersList,
listMaterials, saveMaterial, updateMaterial, deleteMaterial,
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial,
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
fcListDecks, fcCreateDeck, fcAddCard,
escapeHtml, esc,
@@ -1249,6 +1249,7 @@ async function listMaterials() { return req('GET', '/materials'); }
async function saveMaterial(data) { return req('POST', '/materials', data); }
async function updateMaterial(id, d) { return req('PATCH', `/materials/${id}`, d); }
async function deleteMaterial(id) { return req('DELETE', `/materials/${id}`); }
async function shareMaterial(id, d) { return req('POST', `/materials/${id}/share`, d); }
async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); }
async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); }
async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); }