${adminLink}
@@ -1707,7 +1959,6 @@ async function delChatMsg(id) {
function initChat(username) {
chatMyUsername = username;
- document.getElementById('card-chat').style.display = '';
loadChat().then(() => pollChat());
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMsg(); }
@@ -1943,23 +2194,246 @@ if(localStorage.getItem('dark')){
document.getElementById('btn-dark').textContent='☀️';
}
-// ── SIDEBAR ───────────────────────────────────────────────────
+// ── SIDEBAR (small screens overlay) ──────────────────────────
function openSidebar(){
- document.getElementById('sidebar').classList.add('open');
+ document.getElementById('app-sidebar').classList.add('open');
document.getElementById('sidebar-backdrop').classList.add('open');
document.body.style.overflow='hidden';
}
function closeSidebar(){
- document.getElementById('sidebar').classList.remove('open');
+ document.getElementById('app-sidebar').classList.remove('open');
document.getElementById('sidebar-backdrop').classList.remove('open');
document.body.style.overflow='';
}
+// ── OVERLAY CARDS ─────────────────────────────────────────────
+let _activeOverlay = null;
+function openOverlay(id){
+ closeOverlay();
+ const card = document.getElementById(id);
+ if(!card) return;
+ card.style.display = 'flex';
+ card.classList.add('overlay-panel');
+ document.getElementById('fs-backdrop').classList.add('open');
+ document.body.style.overflow='hidden';
+ _activeOverlay = id;
+ lucide.createIcons();
+}
+function closeOverlay(){
+ if(_activeOverlay){
+ const card = document.getElementById(_activeOverlay);
+ if(card){ card.style.display='none'; card.classList.remove('overlay-panel'); }
+ _activeOverlay = null;
+ }
+ document.getElementById('fs-backdrop').classList.remove('open');
+ document.body.style.overflow='';
+}
+
+// kept for any legacy calls
+function toggleFullscreen(id){ openOverlay(id); }
+function collapseAllCards(){ closeOverlay(); }
+
// ── KEYBOARD ──────────────────────────────────────────────────
document.addEventListener('keydown',e=>{
- if(e.key==='Escape'){closeModal();closeSettings();closeSidebar();closeThread();closeNewTicket();}
+ if(e.key==='Escape'){closeModal();closeSettings();closeSidebar();closeOverlay();closeThread();closeNewTicket();}
});
+// ── TEACHER PANEL ─────────────────────────────────────────────
+function teacherTab(name){
+ const names=['materials','announcements','exams','grades'];
+ document.querySelectorAll('.t-tab').forEach((t,i)=>t.classList.toggle('active',names[i]===name));
+ document.querySelectorAll('.t-pane').forEach(p=>p.classList.remove('active'));
+ document.getElementById('t-pane-'+name).classList.add('active');
+ if(name==='grades') loadStudentListForGrades();
+}
+
+async function loadTeacherPanel(){
+ loadTeacherMaterials();
+ loadTeacherAnnouncements();
+ loadTeacherExams();
+ loadTeacherGrades();
+}
+
+async function loadTeacherMaterials(){
+ const mats=await api('GET','teacher/materials');
+ const el=document.getElementById('list-teacher-materials');
+ if(mats.error||!mats.length){el.innerHTML='
`).join('');
+}
+
+async function uploadTeacherMaterial(e){
+ const files=e.target.files;
+ if(!files.length)return;
+ const title=document.getElementById('mat-title').value.trim();
+ if(!title){toast('Bitte einen Titel eingeben','error');e.target.value='';return;}
+ const fd=new FormData();
+ fd.append('file',files[0]);
+ fd.append('title',title);
+ fd.append('class_id','info1');
+ e.target.value='';
+ const r=await fetch('/api/teacher/materials',{method:'POST',body:fd});
+ const d=await r.json();
+ if(!r.ok){toast(d.error||'Upload fehlgeschlagen','error');return;}
+ document.getElementById('mat-title').value='';
+ toast('Material hochgeladen');
+ loadTeacherMaterials();
+}
+
+async function delTeacherMaterial(id){
+ await api('DELETE','teacher/materials/'+id);
+ toast('Gelöscht');
+ loadTeacherMaterials();
+}
+
+async function loadTeacherAnnouncements(){
+ const anns=await api('GET','teacher/announcements');
+ const el=document.getElementById('list-teacher-announcements');
+ if(anns.error||!anns.length){el.innerHTML='
`).join('');
+}
+
+async function createAnnouncement(){
+ const title=document.getElementById('ann-title').value.trim();
+ const content=document.getElementById('ann-content').value.trim();
+ if(!title||!content){toast('Titel und Inhalt erforderlich','error');return;}
+ const r=await api('POST','teacher/announcements',{title,content,class_id:'info1'});
+ if(r.error){toast(r.error,'error');return;}
+ document.getElementById('ann-title').value='';
+ document.getElementById('ann-content').value='';
+ toast('Ankündigung erstellt');
+ loadTeacherAnnouncements();
+}
+
+async function delTeacherAnnouncement(id){
+ await api('DELETE','teacher/announcements/'+id);
+ toast('Gelöscht');
+ loadTeacherAnnouncements();
+}
+
+async function loadTeacherExams(){
+ const exams=await api('GET','teacher/exams');
+ const el=document.getElementById('list-teacher-exams');
+ if(exams.error||!exams.length){el.innerHTML='
';return;}
+ el.innerHTML=exams.map(ex=>{
+ const diff=ex.date?daysUntil(ex.date):null;
+ return `
`;
+ }).join('');
+}
+
+async function createExam(){
+ const title=document.getElementById('exam-title').value.trim();
+ const date=document.getElementById('exam-date').value;
+ const description=document.getElementById('exam-desc').value.trim();
+ if(!title){toast('Titel erforderlich','error');return;}
+ const r=await api('POST','teacher/exams',{title,date:date||null,description:description||null,class_id:'info1'});
+ if(r.error){toast(r.error,'error');return;}
+ document.getElementById('exam-title').value='';
+ document.getElementById('exam-date').value='';
+ document.getElementById('exam-desc').value='';
+ toast('Prüfung angelegt');
+ loadTeacherExams();
+}
+
+async function delTeacherExam(id){
+ await api('DELETE','teacher/exams/'+id);
+ toast('Gelöscht');
+ loadTeacherExams();
+}
+
+async function loadTeacherGrades(){
+ const grades=await api('GET','teacher/grades');
+ const el=document.getElementById('list-teacher-grades');
+ if(grades.error||!grades.length){el.innerHTML='
`).join('');
+}
+
+async function loadStudentListForGrades(){
+ const students=await api('GET','teacher/students');
+ if(students.error)return;
+ const sel=document.getElementById('grade-student');
+ const cur=sel.value;
+ sel.innerHTML='
`).join('');
+ if(cur)sel.value=cur;
+}
+
+async function assignGrade(){
+ const student_id=parseInt(document.getElementById('grade-student').value,10);
+ const grade=parseFloat(document.getElementById('grade-val').value);
+ const type=document.getElementById('grade-type').value;
+ const note=document.getElementById('grade-note').value.trim();
+ if(!student_id){toast('Schüler auswählen','error');return;}
+ if(!grade||grade<1||grade>6){toast('Note muss zwischen 1 und 6 liegen','error');return;}
+ const r=await api('POST','teacher/grades',{student_id,grade,type,note:note||null});
+ if(r.error){toast(r.error,'error');return;}
+ document.getElementById('grade-val').value='';
+ document.getElementById('grade-note').value='';
+ toast('Note vergeben');
+ loadTeacherGrades();
+}
+
+async function delTeacherGrade(id){
+ await api('DELETE','teacher/grades/'+id);
+ toast('Gelöscht');
+ loadTeacherGrades();
+}
+
+// ── STUDENT CLASS CONTENT ──────────────────────────────────────
+async function loadStudentMaterials(){
+ const mats=await api('GET','teacher/materials/class/info1');
+ const el=document.getElementById('list-student-materials');
+ if(!mats||mats.error||!mats.length){el.innerHTML='
`).join('');
+}
+
+async function loadStudentAnnouncements(){
+ const anns=await api('GET','teacher/announcements/class/info1');
+ const el=document.getElementById('list-student-announcements');
+ if(!anns||anns.error||!anns.length){el.innerHTML='
`).join('');
+}
+
// ── START ─────────────────────────────────────────────────────
init();
diff --git a/public/login.html b/public/login.html
index 9010b9d..3b7ec9a 100644
--- a/public/login.html
+++ b/public/login.html
@@ -212,6 +212,7 @@ footer a:hover { color: #2563eb; }
@@ -232,9 +233,22 @@ footer a:hover { color: #2563eb; }
Lehrerkonten werden nach der Registrierung von einem Administrator geprüft und freigeschaltet.
+
+
+
+
-
+
@@ -265,6 +279,7 @@ function selectRole(r) {
document.getElementById('role-teacher').classList.toggle('active', r === 'teacher');
document.getElementById('role-teacher').classList.toggle('teacher', r === 'teacher');
document.getElementById('teacher-notice').classList.toggle('show', r === 'teacher');
+ document.getElementById('subject-field').style.display = r === 'teacher' ? '' : 'none';
document.getElementById('reg-btn').textContent = r === 'teacher' ? 'Als Lehrer/in registrieren' : 'Account erstellen';
}
@@ -292,12 +307,14 @@ async function doLogin(e) {
async function doRegister(e) {
e.preventDefault(); clearErr('reg-err');
+ const body = {
+ email: document.getElementById('r-email').value,
+ password: document.getElementById('r-pass').value,
+ role: selectedRole,
+ };
+ if (selectedRole === 'teacher') body.subject = document.getElementById('r-subject').value;
const r = await fetch('/api/register', { method:'POST', headers:{'Content-Type':'application/json'},
- body: JSON.stringify({
- email: document.getElementById('r-email').value,
- password: document.getElementById('r-pass').value,
- role: selectedRole
- }) });
+ body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) { showErr('reg-err', d.error); return; }
if (d.pending) {
diff --git a/src/auth.js b/src/auth.js
index 55eda8e..d65ce7a 100644
--- a/src/auth.js
+++ b/src/auth.js
@@ -2,7 +2,7 @@ const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET || 'info1-ifb-secret-change-in-prod';
function signToken(user) {
- return jwt.sign({ id: user.id, username: user.username, role: user.role }, SECRET, { expiresIn: '30d' });
+ return jwt.sign({ id: user.id, username: user.username, role: user.role, subject: user.subject || null }, SECRET, { expiresIn: '30d' });
}
function verifyToken(token) {
diff --git a/src/db.js b/src/db.js
index be40a67..18c3e89 100644
--- a/src/db.js
+++ b/src/db.js
@@ -138,9 +138,62 @@ db.exec(`
);
`);
+db.exec(`
+ CREATE TABLE IF NOT EXISTS teacher_materials (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ teacher_id INTEGER NOT NULL,
+ subject TEXT NOT NULL,
+ class_id TEXT NOT NULL DEFAULT 'info1',
+ title TEXT NOT NULL,
+ original_name TEXT NOT NULL,
+ stored_name TEXT NOT NULL UNIQUE,
+ mime_type TEXT NOT NULL,
+ size INTEGER NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (teacher_id) REFERENCES users(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS teacher_announcements (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ teacher_id INTEGER NOT NULL,
+ subject TEXT NOT NULL,
+ class_id TEXT NOT NULL DEFAULT 'info1',
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (teacher_id) REFERENCES users(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS teacher_exams (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ teacher_id INTEGER NOT NULL,
+ subject TEXT NOT NULL,
+ class_id TEXT NOT NULL DEFAULT 'info1',
+ title TEXT NOT NULL,
+ date TEXT,
+ description TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (teacher_id) REFERENCES users(id)
+ );
+
+ CREATE TABLE IF NOT EXISTS teacher_assigned_grades (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ teacher_id INTEGER NOT NULL,
+ student_id INTEGER NOT NULL,
+ subject TEXT NOT NULL,
+ grade REAL NOT NULL,
+ type TEXT DEFAULT 'sonstiges',
+ note TEXT,
+ created_at TEXT DEFAULT (datetime('now')),
+ FOREIGN KEY (teacher_id) REFERENCES users(id),
+ FOREIGN KEY (student_id) REFERENCES users(id)
+ );
+`);
+
// Safe migrations
try { db.exec(`ALTER TABLE grades ADD COLUMN type TEXT DEFAULT 'sonstiges'`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'student'`); } catch {}
try { db.exec(`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'`); } catch {}
+try { db.exec(`ALTER TABLE users ADD COLUMN subject TEXT`); } catch {}
module.exports = db;
diff --git a/src/routes.js b/src/routes.js
index 58b2708..5c328b6 100644
--- a/src/routes.js
+++ b/src/routes.js
@@ -17,9 +17,10 @@ const loginLimiter = rateLimit({
// --- AUTH ---
const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i;
+const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges'];
router.post('/register', (req, res) => {
- const { email, password, role } = req.body;
+ const { email, password, role, subject } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Alle Felder erforderlich' });
if (!IFB_EMAIL_RE.test(email)) return res.status(403).json({ error: 'Ungültige E-Mail-Adresse' });
if (password.length < 6) return res.status(400).json({ error: 'Passwort zu kurz (min. 6 Zeichen)' });
@@ -27,13 +28,21 @@ router.post('/register', (req, res) => {
const safeRole = (role === 'teacher') ? 'teacher' : 'student';
const initialStatus = safeRole === 'teacher' ? 'pending' : 'active';
+ let safeSubject = null;
+ if (safeRole === 'teacher') {
+ if (!subject || !VALID_SUBJECTS.includes(subject)) {
+ return res.status(400).json({ error: 'Bitte ein gültiges Lehrfach auswählen' });
+ }
+ safeSubject = subject;
+ }
+
const hash = bcrypt.hashSync(password, 12);
try {
- const result = db.prepare('INSERT INTO users (username, email, password_hash, role, status) VALUES (?, ?, ?, ?, ?)').run(username, email.toLowerCase(), hash, safeRole, initialStatus);
+ const result = db.prepare('INSERT INTO users (username, email, password_hash, role, status, subject) VALUES (?, ?, ?, ?, ?, ?)').run(username, email.toLowerCase(), hash, safeRole, initialStatus, safeSubject);
if (safeRole === 'teacher') {
return res.json({ ok: true, pending: true });
}
- const user = { id: result.lastInsertRowid, username, role: safeRole };
+ const user = { id: result.lastInsertRowid, username, role: safeRole, subject: null };
const token = signToken(user);
res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' });
res.json({ ok: true, pending: false });
@@ -66,7 +75,7 @@ router.post('/login', loginLimiter, (req, res) => {
}
const token = signToken(user);
res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' });
- res.json({ ok: true, username: user.username, role: user.role });
+ res.json({ ok: true, username: user.username, role: user.role, subject: user.subject });
});
router.post('/logout', (req, res) => {
@@ -75,9 +84,9 @@ router.post('/logout', (req, res) => {
});
router.get('/me', requireAuth, (req, res) => {
- const user = db.prepare('SELECT id, username, email, role, status FROM users WHERE id = ?').get(req.user.id);
+ const user = db.prepare('SELECT id, username, email, role, status, subject FROM users WHERE id = ?').get(req.user.id);
if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' });
- res.json({ username: user.username, id: user.id, role: user.role, email: user.email });
+ res.json({ username: user.username, id: user.id, role: user.role, email: user.email, subject: user.subject });
});
router.put('/me/password', requireAuth, (req, res) => {
diff --git a/src/teacher.js b/src/teacher.js
new file mode 100644
index 0000000..78c6fdc
--- /dev/null
+++ b/src/teacher.js
@@ -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;