From f7357adf1ec31b27e63df66313e5335e0adc4a47 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 12:26:46 +0300 Subject: [PATCH] =?UTF-8?q?feat(materials):=20=D0=A4=D0=B0=D0=B7=D0=B0=206?= =?UTF-8?q?b=20=E2=80=94=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B0=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BC=D0=B0=D1=82=D0=B5=D1=80=D0=B8=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=20=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=BA=D0=B0=D0=BC/=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=81=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../controllers/studentMaterialsController.js | 43 ++++++++++++++++++- backend/src/routes/materials.js | 5 ++- frontend/my-materials.html | 32 ++++++++++++-- js/api.js | 3 +- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/studentMaterialsController.js b/backend/src/controllers/studentMaterialsController.js index 24f773a..cc89127 100644 --- a/backend/src/controllers/studentMaterialsController.js +++ b/backend/src/controllers/studentMaterialsController.js @@ -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 }; diff --git a/backend/src/routes/materials.js b/backend/src/routes/materials.js index 806f0b3..2090da4 100644 --- a/backend/src/routes/materials.js +++ b/backend/src/routes/materials.js @@ -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); diff --git a/frontend/my-materials.html b/frontend/my-materials.html index 186b8cc..e0f58e8 100644 --- a/frontend/my-materials.html +++ b/frontend/my-materials.html @@ -79,7 +79,8 @@ diff --git a/js/api.js b/js/api.js index 564eb7d..98e543f 100644 --- a/js/api.js +++ b/js/api.js @@ -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}`); }