feat: add teacher system with approval flow

- Teacher registration requires subject selection; account starts pending
- Admin approves/rejects via existing admin panel
- Teacher panel (Materialien, Ankündigungen, Prüfungen, Noten) visible only to approved teachers
- Students see class materials and announcements via sidebar overlays
- Teachers can assign grades to students (scoped to own subject)
- New tables: teacher_materials, teacher_announcements, teacher_exams, teacher_assigned_grades
- subject column added to users; included in JWT and /api/me
- requireTeacher middleware fetches fresh status+subject from DB on every request
- Login hint: username is the part of the school email before the @
This commit is contained in:
Simon
2026-04-17 09:28:55 +02:00
parent db5efd8ed9
commit ae789318ba
7 changed files with 965 additions and 130 deletions
+280
View File
@@ -0,0 +1,280 @@
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','application/octet-stream',
]);
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;