const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const db = require('./db'); const { requireAuth } = require('./auth'); const router = express.Router(); const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges']; const VALID_TYPES = ['schulaufgabe','kurzarbeit','stegreifaufgabe','muendlich','sonstiges']; const CLASS_IDS = ['info1']; function requireTeacher(req, res, next) { if (req.user.role !== 'teacher') return res.status(403).json({ error: 'Nur für Lehrkräfte' }); const user = db.prepare('SELECT status, subject FROM users WHERE id = ?').get(req.user.id); if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' }); if (!user.subject) return res.status(403).json({ error: 'Kein Lehrfach zugewiesen' }); req.teacher = user; next(); } const STORAGE_DIR = path.resolve(__dirname, '../storage'); fs.mkdirSync(STORAGE_DIR, { recursive: true }); const ALLOWED_EXT = new Set([ 'pdf','doc','docx','xls','xlsx','ppt','pptx','odt','ods','odp','rtf','txt','md', 'jpg','jpeg','png','gif','webp','bmp','tiff', 'zip','rar','7z','csv', ]); const ALLOWED_MIME = new Set([ 'application/pdf','application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.oasis.opendocument.text', 'application/vnd.oasis.opendocument.spreadsheet', 'application/vnd.oasis.opendocument.presentation', 'application/rtf','text/rtf','text/plain','text/markdown', 'image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff', 'application/zip','application/x-zip-compressed', 'application/vnd.rar','application/x-rar-compressed', 'application/x-7z-compressed','text/csv', ]); const EXT_MIME = { pdf:'application/pdf', doc:'application/msword', docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls:'application/vnd.ms-excel', xlsx:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt:'application/vnd.ms-powerpoint', pptx:'application/vnd.openxmlformats-officedocument.presentationml.presentation', odt:'application/vnd.oasis.opendocument.text', ods:'application/vnd.oasis.opendocument.spreadsheet', odp:'application/vnd.oasis.opendocument.presentation', rtf:'application/rtf', txt:'text/plain', md:'text/plain', jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif', webp:'image/webp', bmp:'image/bmp', tiff:'image/tiff', zip:'application/zip', rar:'application/vnd.rar', '7z':'application/x-7z-compressed', csv:'text/csv', }; const upload = multer({ storage: multer.diskStorage({ destination: (_req, _file, cb) => cb(null, STORAGE_DIR), filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase().slice(1); cb(null, crypto.randomUUID() + (ext ? '.' + ext : '')); }, }), limits: { fileSize: 50 * 1024 * 1024 }, fileFilter(_req, file, cb) { const ext = path.extname(file.originalname).toLowerCase().slice(1); if (!ext || !ALLOWED_EXT.has(ext)) return cb(Object.assign(new Error('INVALID_EXT'), { code: 'INVALID_EXT' })); if (file.mimetype && !ALLOWED_MIME.has(file.mimetype)) return cb(Object.assign(new Error('INVALID_MIME'), { code: 'INVALID_MIME' })); cb(null, true); }, }); // ── MATERIALS ────────────────────────────────────────────────── router.get('/materials', requireAuth, requireTeacher, (req, res) => { res.json(db.prepare( 'SELECT id, subject, class_id, title, original_name, mime_type, size, created_at FROM teacher_materials WHERE teacher_id = ? ORDER BY created_at DESC' ).all(req.user.id)); }); router.post('/materials', requireAuth, requireTeacher, (req, res) => { upload.single('file')(req, res, (err) => { if (err) { if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'Datei zu groß (max. 50 MB)' }); if (err.code === 'INVALID_EXT') return res.status(400).json({ error: 'Dateityp nicht erlaubt' }); if (err.code === 'INVALID_MIME') return res.status(400).json({ error: 'MIME-Typ nicht erlaubt' }); return res.status(400).json({ error: 'Upload fehlgeschlagen' }); } if (!req.file) return res.status(400).json({ error: 'Keine Datei angegeben' }); const title = typeof req.body.title === 'string' ? req.body.title.trim() : ''; if (!title || title.length > 200) { fs.unlink(req.file.path, () => {}); return res.status(400).json({ error: 'Titel erforderlich (max. 200 Zeichen)' }); } const safeClass = CLASS_IDS.includes(req.body.class_id) ? req.body.class_id : 'info1'; const ext = path.extname(req.file.originalname).toLowerCase().slice(1); const mime = EXT_MIME[ext] || 'application/octet-stream'; const result = db.prepare( 'INSERT INTO teacher_materials (teacher_id, subject, class_id, title, original_name, stored_name, mime_type, size) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ).run(req.user.id, req.teacher.subject, safeClass, title, req.file.originalname, req.file.filename, mime, req.file.size); res.json({ id: result.lastInsertRowid, title, original_name: req.file.originalname, mime_type: mime, size: req.file.size }); }); }); router.delete('/materials/:id', requireAuth, requireTeacher, (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'Ungültige ID' }); const mat = db.prepare('SELECT * FROM teacher_materials WHERE id = ? AND teacher_id = ?').get(id, req.user.id); if (!mat) return res.status(404).json({ error: 'Material nicht gefunden' }); fs.unlink(path.join(STORAGE_DIR, mat.stored_name), () => {}); db.prepare('DELETE FROM teacher_materials WHERE id = ?').run(id); res.json({ ok: true }); }); router.get('/materials/class/:class_id', requireAuth, (req, res) => { const classId = CLASS_IDS.includes(req.params.class_id) ? req.params.class_id : 'info1'; res.json(db.prepare(` SELECT m.id, m.subject, m.title, m.original_name, m.mime_type, m.size, m.created_at, u.username AS teacher_name FROM teacher_materials m JOIN users u ON u.id = m.teacher_id WHERE m.class_id = ? ORDER BY m.created_at DESC `).all(classId)); }); router.get('/materials/:id/download', requireAuth, (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'Ungültige ID' }); const mat = db.prepare('SELECT * FROM teacher_materials WHERE id = ?').get(id); if (!mat) return res.status(404).json({ error: 'Material nicht gefunden' }); const filePath = path.join(STORAGE_DIR, mat.stored_name); if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Datei nicht gefunden' }); res.setHeader('Content-Type', mat.mime_type); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(mat.original_name)}`); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Cache-Control', 'private, no-cache'); res.sendFile(filePath); }); // ── ANNOUNCEMENTS ────────────────────────────────────────────── router.get('/announcements', requireAuth, requireTeacher, (req, res) => { res.json(db.prepare( 'SELECT id, subject, class_id, title, content, created_at FROM teacher_announcements WHERE teacher_id = ? ORDER BY created_at DESC' ).all(req.user.id)); }); router.post('/announcements', requireAuth, requireTeacher, (req, res) => { const { title, content, class_id } = req.body; if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' }); if (!content || typeof content !== 'string') return res.status(400).json({ error: 'Inhalt erforderlich' }); const safeTitle = title.trim(); const safeContent = content.trim(); if (!safeTitle || safeTitle.length > 200) return res.status(400).json({ error: 'Titel ungültig (max. 200 Zeichen)' }); if (!safeContent || safeContent.length > 5000) return res.status(400).json({ error: 'Inhalt zu lang (max. 5000 Zeichen)' }); const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1'; const result = db.prepare( 'INSERT INTO teacher_announcements (teacher_id, subject, class_id, title, content) VALUES (?, ?, ?, ?, ?)' ).run(req.user.id, req.teacher.subject, safeClass, safeTitle, safeContent); res.json({ id: result.lastInsertRowid }); }); router.delete('/announcements/:id', requireAuth, requireTeacher, (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'Ungültige ID' }); const ann = db.prepare('SELECT id FROM teacher_announcements WHERE id = ? AND teacher_id = ?').get(id, req.user.id); if (!ann) return res.status(404).json({ error: 'Ankündigung nicht gefunden' }); db.prepare('DELETE FROM teacher_announcements WHERE id = ?').run(id); res.json({ ok: true }); }); router.get('/announcements/class/:class_id', requireAuth, (req, res) => { const classId = CLASS_IDS.includes(req.params.class_id) ? req.params.class_id : 'info1'; res.json(db.prepare(` SELECT a.id, a.subject, a.title, a.content, a.created_at, u.username AS teacher_name FROM teacher_announcements a JOIN users u ON u.id = a.teacher_id WHERE a.class_id = ? ORDER BY a.created_at DESC `).all(classId)); }); // ── EXAMS ────────────────────────────────────────────────────── router.get('/exams', requireAuth, requireTeacher, (req, res) => { res.json(db.prepare( 'SELECT id, subject, class_id, title, date, description, created_at FROM teacher_exams WHERE teacher_id = ? ORDER BY date ASC NULLS LAST' ).all(req.user.id)); }); router.post('/exams', requireAuth, requireTeacher, (req, res) => { const { title, date, description, class_id } = req.body; if (!title || typeof title !== 'string') return res.status(400).json({ error: 'Titel erforderlich' }); const safeTitle = title.trim(); if (!safeTitle || safeTitle.length > 200) return res.status(400).json({ error: 'Titel ungültig (max. 200 Zeichen)' }); const safeDate = date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null; const safeDesc = description && typeof description === 'string' ? description.trim().slice(0, 1000) : null; const safeClass = CLASS_IDS.includes(class_id) ? class_id : 'info1'; const result = db.prepare( 'INSERT INTO teacher_exams (teacher_id, subject, class_id, title, date, description) VALUES (?, ?, ?, ?, ?, ?)' ).run(req.user.id, req.teacher.subject, safeClass, safeTitle, safeDate, safeDesc); res.json({ id: result.lastInsertRowid }); }); router.delete('/exams/:id', requireAuth, requireTeacher, (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'Ungültige ID' }); const exam = db.prepare('SELECT id FROM teacher_exams WHERE id = ? AND teacher_id = ?').get(id, req.user.id); if (!exam) return res.status(404).json({ error: 'Prüfung nicht gefunden' }); db.prepare('DELETE FROM teacher_exams WHERE id = ?').run(id); res.json({ ok: true }); }); router.get('/exams/class/:class_id', requireAuth, (req, res) => { const classId = CLASS_IDS.includes(req.params.class_id) ? req.params.class_id : 'info1'; res.json(db.prepare(` SELECT e.id, e.subject, e.title, e.date, e.description, e.created_at, u.username AS teacher_name FROM teacher_exams e JOIN users u ON u.id = e.teacher_id WHERE e.class_id = ? ORDER BY e.date ASC NULLS LAST `).all(classId)); }); // ── GRADES ───────────────────────────────────────────────────── router.get('/grades', requireAuth, requireTeacher, (req, res) => { res.json(db.prepare(` SELECT g.id, g.student_id, g.subject, g.grade, g.type, g.note, g.created_at, u.username AS student_name FROM teacher_assigned_grades g JOIN users u ON u.id = g.student_id WHERE g.teacher_id = ? ORDER BY g.created_at DESC `).all(req.user.id)); }); router.post('/grades', requireAuth, requireTeacher, (req, res) => { const { student_id, grade, type, note } = req.body; if (!student_id) return res.status(400).json({ error: 'Schüler erforderlich' }); const gradeNum = parseFloat(grade); if (isNaN(gradeNum) || gradeNum < 1 || gradeNum > 6) return res.status(400).json({ error: 'Note muss zwischen 1 und 6 liegen' }); const safeType = VALID_TYPES.includes(type) ? type : 'sonstiges'; const safeNote = note && typeof note === 'string' ? note.trim().slice(0, 500) : null; const student = db.prepare("SELECT id, role FROM users WHERE id = ? AND status = 'active'").get(parseInt(student_id, 10)); if (!student || student.role !== 'student') return res.status(404).json({ error: 'Schüler nicht gefunden' }); const result = db.prepare( 'INSERT INTO teacher_assigned_grades (teacher_id, student_id, subject, grade, type, note) VALUES (?, ?, ?, ?, ?, ?)' ).run(req.user.id, student.id, req.teacher.subject, gradeNum, safeType, safeNote); res.json({ id: result.lastInsertRowid }); }); router.delete('/grades/:id', requireAuth, requireTeacher, (req, res) => { const id = parseInt(req.params.id, 10); if (!id) return res.status(400).json({ error: 'Ungültige ID' }); const gr = db.prepare('SELECT id FROM teacher_assigned_grades WHERE id = ? AND teacher_id = ?').get(id, req.user.id); if (!gr) return res.status(404).json({ error: 'Note nicht gefunden' }); db.prepare('DELETE FROM teacher_assigned_grades WHERE id = ?').run(id); res.json({ ok: true }); }); router.get('/students', requireAuth, requireTeacher, (req, res) => { res.json(db.prepare("SELECT id, username FROM users WHERE role = 'student' AND status = 'active' ORDER BY username").all()); }); router.get('/my-grades', requireAuth, (req, res) => { if (req.user.role !== 'student') return res.status(403).json({ error: 'Nur für Schüler' }); res.json(db.prepare(` SELECT g.id, g.subject, g.grade, g.type, g.note, g.created_at, u.username AS teacher_name FROM teacher_assigned_grades g JOIN users u ON u.id = g.teacher_id WHERE g.student_id = ? ORDER BY g.created_at DESC `).all(req.user.id)); }); module.exports = router;