'use strict'; /* Student-owned personal materials ("Мои материалы"). * 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']; /* GET /api/materials — list the current user's saved materials + their collections */ function list(req, res) { const uid = req.user.id; const materials = db.prepare(` SELECT id, kind, title, body, url, source_session_id, source_title, collection_id, tags, created_at FROM student_materials WHERE user_id = ? ORDER BY created_at DESC, id DESC `).all(uid); const collections = db.prepare(` SELECT c.id, c.name, c.color, c.sort_order, (SELECT COUNT(*) FROM student_materials m WHERE m.collection_id = c.id) AS count FROM material_collections c WHERE c.user_id = ? ORDER BY c.sort_order, c.id `).all(uid); res.json({ materials, collections }); } /* Validate that a collection id belongs to the user; returns null if unset/invalid. */ function ownCollectionId(raw, uid) { if (raw === null || raw === '' || raw === undefined) return null; const cid = Number(raw); if (!Number.isFinite(cid)) return null; const own = db.prepare('SELECT 1 FROM material_collections WHERE id = ? AND user_id = ?').get(cid, uid); return own ? cid : null; } /* POST /api/materials — save a new item to the current user's collection. Body: { kind, title?, body?, url?, sourceSessionId?, sourceTitle? } */ function create(req, res) { const b = req.body || {}; const kind = String(b.kind || ''); if (!KINDS.includes(kind)) return res.status(400).json({ error: 'invalid kind' }); const title = String(b.title || '').slice(0, 300); const body = b.body != null ? String(b.body).slice(0, 60000) : null; const url = b.url != null ? String(b.url).slice(0, 2000) : null; if ((kind === 'note') && !body) return res.status(400).json({ error: 'body required for note' }); if ((kind === 'board' || kind === 'image' || kind === 'link') && !url) return res.status(400).json({ error: 'url required' }); // Soft reference to the source session (kept readable via source_title even // after the session is deleted). Only store the id if the session exists. let sourceSessionId = Number(b.sourceSessionId); if (!Number.isFinite(sourceSessionId)) sourceSessionId = null; else if (!db.prepare('SELECT 1 FROM classroom_sessions WHERE id = ?').get(sourceSessionId)) sourceSessionId = null; const sourceTitle = b.sourceTitle != null ? String(b.sourceTitle).slice(0, 300) : null; const collectionId = ownCollectionId(b.collection_id, req.user.id); const tags = b.tags != null ? String(b.tags).slice(0, 500) : null; const r = db.prepare(` INSERT INTO student_materials (user_id, kind, title, body, url, source_session_id, source_title, collection_id, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(req.user.id, kind, title, body, url, sourceSessionId, sourceTitle, collectionId, tags); res.status(201).json({ id: Number(r.lastInsertRowid) }); } /* PATCH /api/materials/:id — rename / edit one of the current user's items. Editable: title, body, url (e.g. re-saving an annotated image), collection_id, tags. */ function update(req, res) { const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); const b = req.body || {}; const fields = [], args = []; if (b.title !== undefined) { fields.push('title = ?'); args.push(String(b.title || '').slice(0, 300)); } if (b.body !== undefined) { fields.push('body = ?'); args.push(b.body != null ? String(b.body).slice(0, 60000) : null); } if (b.url !== undefined) { fields.push('url = ?'); args.push(b.url != null ? String(b.url).slice(0, 2000) : null); } if (b.collection_id !== undefined) { fields.push('collection_id = ?'); args.push(ownCollectionId(b.collection_id, req.user.id)); } if (b.tags !== undefined) { fields.push('tags = ?'); args.push(b.tags != null ? String(b.tags).slice(0, 500) : null); } if (!fields.length) return res.json({ ok: true }); args.push(req.params.id); db.prepare(`UPDATE student_materials SET ${fields.join(', ')} WHERE id = ?`).run(...args); res.json({ ok: true }); } /* DELETE /api/materials/:id — remove one of the current user's items */ function remove(req, res) { const row = db.prepare('SELECT user_id FROM student_materials WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); db.prepare('DELETE FROM student_materials WHERE id = ?').run(req.params.id); res.json({ ok: true }); } /* ── Collections (folders) ──────────────────────────────────────────── */ /* POST /api/materials/collections — create a folder */ function createCollection(req, res) { const name = String((req.body && req.body.name) || '').trim().slice(0, 120); if (!name) return res.status(400).json({ error: 'name required' }); const color = req.body && req.body.color ? String(req.body.color).slice(0, 20) : null; const r = db.prepare( 'INSERT INTO material_collections (user_id, name, color, sort_order) VALUES (?, ?, ?, ?)' ).run(req.user.id, name, color, Number(req.body && req.body.sortOrder) || 0); res.status(201).json({ id: Number(r.lastInsertRowid) }); } /* PATCH /api/materials/collections/:id — rename / recolor / reorder */ function updateCollection(req, res) { const row = db.prepare('SELECT user_id FROM material_collections WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); const b = req.body || {}; const fields = [], args = []; if (b.name !== undefined) { fields.push('name = ?'); args.push(String(b.name || '').trim().slice(0, 120)); } if (b.color !== undefined) { fields.push('color = ?'); args.push(b.color ? String(b.color).slice(0, 20) : null); } if (b.sortOrder !== undefined) { fields.push('sort_order = ?'); args.push(Number(b.sortOrder) || 0); } if (!fields.length) return res.json({ ok: true }); args.push(req.params.id); db.prepare(`UPDATE material_collections SET ${fields.join(', ')} WHERE id = ?`).run(...args); res.json({ ok: true }); } /* DELETE /api/materials/collections/:id — delete folder (materials kept, uncategorised) */ function deleteCollection(req, res) { const row = db.prepare('SELECT user_id FROM material_collections WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'not found' }); if (row.user_id !== req.user.id) return res.status(403).json({ error: 'forbidden' }); db.prepare('DELETE FROM material_collections WHERE id = ?').run(req.params.id); // ON DELETE SET NULL res.json({ ok: true }); } /* 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)) return res.status(400).json({ error: 'bad userId' }); // Раздавать можно только своему ученику (в своём классе или в teacher_students). if (req.user.role !== 'admin') { const linked = 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(uid, req.user.id, uid, req.user.id); if (!linked) return res.status(403).json({ error: 'Этот ученик не привязан к вам' }); } 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 };