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);