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
+2
View File
@@ -3,6 +3,7 @@ const cookieParser = require('cookie-parser');
const path = require('path');
const routes = require('./src/routes');
const { router: filesRouter } = require('./src/files');
const teacherRouter = require('./src/teacher');
const app = express();
const PORT = 3010;
@@ -12,6 +13,7 @@ app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api', routes);
app.use('/api/files', filesRouter);
app.use('/api/teacher', teacherRouter);
const html = f => (req, res) => res.sendFile(path.join(__dirname, 'public', f));
+591 -117
View File
@@ -793,60 +793,135 @@ footer {
.col-primary > *:not(#card-tt) { display: none !important; }
}
/* ── SIDEBAR ─────────────────────────────────────────────── */
/* ── PAGE LAYOUT ─────────────────────────────────────────────── */
.sidebar-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,.32);
z-index: 150;
opacity: 0; pointer-events: none;
transition: opacity .2s;
.page-body {
display: flex;
flex: 1;
align-items: stretch;
min-height: 0;
}
.sidebar-backdrop.open { opacity: 1; pointer-events: all; }
.sidebar {
position: fixed; top: 0; right: 0;
width: 360px; max-width: 100vw; height: 100%;
background: var(--bg);
border-left: 1px solid var(--border);
box-shadow: var(--shadow-lg);
z-index: 160;
transform: translateX(100%);
transition: transform .22s cubic-bezier(.4,0,.2,1);
display: flex; flex-direction: column;
overflow: hidden;
}
.sidebar.open { transform: translateX(0); }
/* ── LEFT SIDEBAR ─────────────────────────────────────────────── */
.sidebar-head {
height: 54px;
padding: 0 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; gap: 10px;
background: var(--surface);
.app-sidebar {
width: 296px;
flex-shrink: 0;
border-right: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 54px;
height: calc(100vh - 54px);
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--n-200) transparent;
display: flex;
flex-direction: column;
z-index: 10;
}
.sidebar-title { font-size: 13px; font-weight: 600; color: var(--text); }
.sidebar-close {
width: 28px; height: 28px;
.app-sidebar-inner {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.sb-nav-item {
display: flex; align-items: center; gap: 9px;
width: 100%; padding: 9px 10px;
border: none; background: none; cursor: pointer;
border-radius: var(--r-sm);
font-size: 13px; font-weight: 500; color: var(--text);
font-family: inherit; text-align: left;
transition: background .12s, color .12s;
}
.sb-nav-item:hover { background: var(--n-100); color: var(--blue); }
.sb-nav-item .lucide { width: 15px; height: 15px; color: var(--text-muted); flex-shrink: 0; }
.sb-nav-item:hover .lucide { color: var(--blue); }
.sb-nav-arrow { margin-left: auto; }
/* Overlay panel */
.overlay-panel {
position: fixed !important; inset: 16px !important;
z-index: 260; border-radius: var(--r-xl) !important;
overflow: hidden !important; box-shadow: var(--shadow-xl) !important;
flex-direction: column;
}
.overlay-panel .card-body {
overflow-y: auto;
flex: 1;
}
.sb-group-label {
font-size: 10px;
font-weight: 700;
letter-spacing: .6px;
text-transform: uppercase;
color: var(--text-subtle);
padding: 6px 6px 2px;
margin-top: 2px;
}
.sb-group-label:first-child { margin-top: 0; padding-top: 2px; }
/* Expand-to-fullscreen button */
.card-expand-btn {
width: 24px; height: 24px;
border: none; background: none; cursor: pointer;
border-radius: var(--r-sm);
display: flex; align-items: center; justify-content: center;
font-size: 18px; line-height: 1; color: var(--text-muted);
transition: background .1s; font-family: inherit;
color: var(--text-subtle);
transition: background .1s, color .1s;
flex-shrink: 0;
padding: 0;
}
.sidebar-close:hover { background: var(--n-100); color: var(--text); }
.card-expand-btn:hover { background: var(--n-100); color: var(--text); }
.card-expand-btn .lucide { width: 13px; height: 13px; }
.sidebar-body {
flex: 1; overflow-y: auto;
padding: 14px;
display: flex; flex-direction: column; gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--n-200) transparent;
/* Fullscreen card overlay */
.card.fullscreen {
position: fixed !important;
inset: 16px !important;
z-index: 260;
border-radius: var(--r-xl) !important;
overflow: auto !important;
box-shadow: var(--shadow-xl) !important;
display: flex !important;
flex-direction: column;
max-width: none !important;
}
.card.fullscreen .card-body {
flex: 1;
overflow-y: auto;
max-width: 860px;
width: 100%;
margin: 0 auto;
}
.card.fullscreen .chat-msgs { height: auto; min-height: 300px; flex: 1; }
/* burger button badge dot (optional active state) */
#sidebar-btn { position: relative; }
/* Fullscreen backdrop */
#fs-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,.45);
z-index: 259;
opacity: 0; pointer-events: none;
transition: opacity .2s;
}
#fs-backdrop.open { opacity: 1; pointer-events: all; }
/* Sidebar overlay (small screens) */
.sidebar-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,.32);
z-index: 140;
opacity: 0; pointer-events: none;
transition: opacity .2s;
display: none;
}
.sidebar-backdrop.open { opacity: 1; pointer-events: all; }
#sidebar-btn { position: relative; font-size: 18px; }
/* ── RESPONSIVE ──────────────────────────────────────────── */
@@ -866,7 +941,29 @@ footer {
.thread-reply-row textarea { flex:1; border:1.5px solid var(--border); border-radius:var(--r-sm); padding:8px 10px; font-size:13px; font-family:inherit; resize:none; outline:none; background:var(--surface); color:var(--text); transition:border-color .12s; }
.thread-reply-row textarea:focus { border-color:var(--blue); }
@media (max-width: 860px) {
/* Large: sidebar always visible, no burger */
@media (min-width: 1101px) {
#sidebar-btn { display: none !important; }
.sidebar-backdrop { display: none !important; }
}
/* Small: sidebar becomes overlay, burger appears */
@media (max-width: 1100px) {
.app-sidebar {
position: fixed;
top: 54px; left: 0;
height: calc(100vh - 54px);
transform: translateX(-100%);
transition: transform .22s cubic-bezier(.4,0,.2,1);
box-shadow: var(--shadow-lg);
z-index: 150;
}
.app-sidebar.open { transform: translateX(0); }
.sidebar-backdrop { display: block; }
#sidebar-btn { display: flex !important; }
}
@media (max-width: 900px) {
.main-grid { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
@@ -881,6 +978,15 @@ footer {
footer { padding: 11px 14px; }
}
/* ── TEACHER TABS ────────────────────────────────────────── */
.t-tabs { display:flex; border-bottom:1px solid var(--border); margin-bottom:12px; gap:0; overflow-x:auto; }
.t-tab { padding:8px 14px; font-size:12px; font-weight:600; color:var(--text-muted); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; transition:color .12s, border-color .12s; white-space:nowrap; flex-shrink:0; }
.t-tab.active { color:var(--blue); border-bottom-color:var(--blue); }
.t-pane { display:none; }
.t-pane.active { display:block; }
.t-input { width:100%; padding:7px 10px; border:1px solid var(--border); border-radius:var(--r-sm); font-size:13px; font-family:inherit; background:var(--surface); color:var(--text); outline:none; transition:border-color .12s; }
.t-input:focus { border-color:var(--blue); }
/* ── ICONS ───────────────────────────────────────────────── */
.lucide {
display: inline-block;
@@ -908,6 +1014,8 @@ footer {
<body>
<header>
<button id="sidebar-btn" class="h-icon-btn" onclick="openSidebar()" title="Menü" aria-label="Seitenleiste öffnen">&#9776;</button>
<div class="brand" onclick="location.href='/'">
<div class="brand-mark">i1</div>
<div class="brand-text">
@@ -922,8 +1030,7 @@ footer {
<div class="h-right">
<div id="weather"></div>
<div id="clock"></div>
<button id="btn-dark" class="h-icon-btn" onclick="toggleDark()" title="Dark Mode"><i data-lucide="moon" aria-label="Dark Mode"></i></button>
<button id="sidebar-btn" class="h-icon-btn" style="display:none" onclick="openSidebar()" title="Noten, Dateien & Fehlzeiten"><i data-lucide="menu" aria-label="Seitenleiste öffnen"></i></button>
<button id="btn-dark" class="h-icon-btn" onclick="toggleDark()" title="Dark Mode"><i data-lucide="moon"></i></button>
<div id="h-user" style="display:flex;align-items:center;gap:7px">
<a href="/login" class="h-btn">Anmelden</a>
<a href="/login?tab=register" class="h-btn h-btn-primary">Registrieren</a>
@@ -945,6 +1052,200 @@ footer {
<button class="banner-cta" onclick="location.href='/login?tab=register'">Jetzt registrieren →</button>
</div>
<div class="page-body">
<aside class="app-sidebar" id="app-sidebar">
<div class="app-sidebar-inner">
<div class="sb-group-label">Persönlich</div>
<button class="sb-nav-item" id="sb-nav-grades" style="display:none" onclick="openOverlay('card-grades');closeSidebar()">
<i data-lucide="graduation-cap"></i><span>Noten</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<button class="sb-nav-item" id="sb-nav-files" style="display:none" onclick="openOverlay('card-files');closeSidebar()">
<i data-lucide="folder"></i><span>Dateispeicher</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<button class="sb-nav-item" id="sb-nav-ab" style="display:none" onclick="openOverlay('card-ab');closeSidebar()">
<i data-lucide="user-x"></i><span>Fehlzeiten</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<div class="sb-group-label" id="sb-label-komm" style="display:none">Kommunikation</div>
<button class="sb-nav-item" id="sb-nav-chat" style="display:none" onclick="openOverlay('card-chat');closeSidebar()">
<i data-lucide="message-square"></i><span>Klassen-Chat</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<button class="sb-nav-item" id="sb-nav-tickets" style="display:none" onclick="openOverlay('card-tickets');closeSidebar()">
<i data-lucide="ticket"></i><span>Support-Tickets</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<div class="sb-group-label" id="sb-label-klasse" style="display:none">Klasse</div>
<button class="sb-nav-item" id="sb-nav-materials" style="display:none" onclick="openOverlay('card-materials');loadStudentMaterials();closeSidebar()">
<i data-lucide="book-open"></i><span>Materialien</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<button class="sb-nav-item" id="sb-nav-announcements" style="display:none" onclick="openOverlay('card-announcements');loadStudentAnnouncements();closeSidebar()">
<i data-lucide="megaphone"></i><span>Ankündigungen</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
<div class="sb-group-label" id="sb-label-lehrer" style="display:none">Lehrer</div>
<button class="sb-nav-item" id="sb-nav-teacher" style="display:none" onclick="openOverlay('card-teacher');loadTeacherPanel();closeSidebar()">
<i data-lucide="user-cog"></i><span>Lehrer-Panel</span><i data-lucide="chevron-right" class="sb-nav-arrow"></i>
</button>
</div>
</aside>
<!-- Overlay cards hidden until openOverlay() called -->
<input type="file" id="file-input" style="display:none" multiple onchange="uploadFiles(event)">
<div class="card ov-card" id="card-grades" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="graduation-cap" aria-hidden="true"></i> Noten</div>
<div class="card-actions">
<button class="add-btn" onclick="openModal('grades')">+ Note</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-grades"></div>
</div>
<div class="card ov-card" id="card-files" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="folder" aria-hidden="true"></i> Dateispeicher</div>
<div class="card-actions">
<button class="add-btn" onclick="document.getElementById('file-input').click()">↑ Hochladen</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="files-body">
<div class="upload-drop" id="files-drop">Dateien hier ablegen oder oben hochladen</div>
<div class="sk-line sk"></div>
</div>
</div>
<div class="card ov-card" id="card-ab" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="user-x" aria-hidden="true"></i> Fehlzeiten</div>
<div class="card-actions">
<button class="add-btn" onclick="openModal('absences')">+ Eintragen</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-ab"></div>
</div>
<div class="card ov-card" id="card-chat" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="message-square" aria-hidden="true"></i> Klassen-Chat</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" style="display:flex;flex-direction:column;flex:1">
<div class="chat-msgs" id="chat-msgs" style="flex:1;overflow-y:auto"></div>
<div class="chat-input-row">
<input class="chat-input" id="chat-input" type="text" placeholder="Nachricht schreiben…" maxlength="500" autocomplete="off">
<button class="add-btn" onclick="sendChatMsg()">Senden</button>
</div>
</div>
</div>
<div class="card ov-card" id="card-tickets" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="ticket" aria-hidden="true"></i> Support-Tickets</div>
<div class="card-actions">
<button class="add-btn" onclick="openNewTicket()">+ Ticket</button>
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-tickets"><div class="empty">Keine Tickets</div></div>
</div>
<!-- STUDENT MATERIALS OVERLAY -->
<div class="card ov-card" id="card-materials" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="book-open" aria-hidden="true"></i> Materialien</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-student-materials"><div class="sk-line sk"></div></div>
</div>
<!-- STUDENT ANNOUNCEMENTS OVERLAY -->
<div class="card ov-card" id="card-announcements" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="megaphone" aria-hidden="true"></i> Ankündigungen</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body" id="list-student-announcements"><div class="sk-line sk"></div></div>
</div>
<!-- TEACHER PANEL OVERLAY -->
<input type="file" id="teacher-file-input" style="display:none" onchange="uploadTeacherMaterial(event)">
<div class="card ov-card" id="card-teacher" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="user-cog" aria-hidden="true"></i> Lehrer-Panel</div>
<div class="card-actions">
<button class="card-expand-btn" onclick="closeOverlay()" title="Schließen"><i data-lucide="x"></i></button>
</div>
</div>
<div class="card-body">
<div class="t-tabs">
<div class="t-tab active" onclick="teacherTab('materials')">Materialien</div>
<div class="t-tab" onclick="teacherTab('announcements')">Ankündigungen</div>
<div class="t-tab" onclick="teacherTab('exams')">Prüfungen</div>
<div class="t-tab" onclick="teacherTab('grades')">Noten</div>
</div>
<div id="t-pane-materials" class="t-pane active">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input type="text" id="mat-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200" style="flex:1;min-width:160px">
<button class="add-btn" onclick="document.getElementById('teacher-file-input').click()">↑ Datei hochladen</button>
</div>
<div id="list-teacher-materials"><div class="empty">Keine Materialien hochgeladen</div></div>
</div>
<div id="t-pane-announcements" class="t-pane">
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">
<input type="text" id="ann-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200">
<textarea id="ann-content" class="t-input" placeholder="Inhalt (max. 5000 Zeichen)" maxlength="5000" rows="3" style="resize:vertical"></textarea>
<button class="add-btn" style="align-self:flex-start" onclick="createAnnouncement()">+ Ankündigung</button>
</div>
<div id="list-teacher-announcements"><div class="empty">Keine Ankündigungen</div></div>
</div>
<div id="t-pane-exams" class="t-pane">
<div style="display:flex;flex-direction:column;gap:8px;margin-bottom:12px">
<input type="text" id="exam-title" class="t-input" placeholder="Titel (max. 200 Zeichen)" maxlength="200">
<div style="display:flex;gap:8px;flex-wrap:wrap">
<input type="date" id="exam-date" class="t-input" style="flex:1;min-width:140px">
<input type="text" id="exam-desc" class="t-input" placeholder="Beschreibung (optional)" maxlength="1000" style="flex:2;min-width:160px">
</div>
<button class="add-btn" style="align-self:flex-start" onclick="createExam()">+ Prüfung anlegen</button>
</div>
<div id="list-teacher-exams"><div class="empty">Keine Prüfungen angelegt</div></div>
</div>
<div id="t-pane-grades" class="t-pane">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:flex-end">
<select id="grade-student" class="t-input" style="flex:2;min-width:140px">
<option value="">Schüler auswählen…</option>
</select>
<select id="grade-type" class="t-input" style="flex:1;min-width:120px">
<option value="schulaufgabe">Schulaufgabe</option>
<option value="kurzarbeit">Kurzarbeit</option>
<option value="stegreifaufgabe">Stegreifaufgabe</option>
<option value="muendlich">Mündlich</option>
<option value="sonstiges">Sonstiges</option>
</select>
<input type="number" id="grade-val" class="t-input" min="1" max="6" step="0.5" placeholder="Note (16)" style="flex:1;min-width:100px">
<input type="text" id="grade-note" class="t-input" placeholder="Anmerkung (optional)" maxlength="500" style="flex:2;min-width:140px">
<button class="add-btn" onclick="assignGrade()">Note vergeben</button>
</div>
<div id="list-teacher-grades"><div class="empty">Keine Noten vergeben</div></div>
</div>
</div>
</div>
<main>
<div class="main-grid">
@@ -1048,72 +1349,11 @@ footer {
</div><!-- /col-secondary -->
</div><!-- /main-grid -->
<!-- Chat (full width) -->
<div class="card" id="card-chat" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="message-square" aria-hidden="true"></i> Klassen-Chat · INFO1</div>
</div>
<div class="card-body">
<div class="chat-msgs" id="chat-msgs"></div>
<div class="chat-input-row">
<input class="chat-input" id="chat-input" type="text" placeholder="Nachricht schreiben…" maxlength="500" autocomplete="off">
<button class="add-btn" onclick="sendChatMsg()">Senden</button>
</div>
</div>
</div>
<!-- Support Tickets (full width, logged-in) -->
<div class="card" id="card-tickets" style="display:none">
<div class="card-head">
<div class="card-title"><span class="ci">🎫</span> Support-Tickets</div>
<button class="add-btn" onclick="openNewTicket()">+ Ticket</button>
</div>
<div class="card-body" id="list-tickets"><div class="empty">Keine Tickets</div></div>
</div>
</main>
</div><!-- /page-body -->
<!-- ── SIDEBAR ──────────────────────────────────────────── -->
<div class="sidebar-backdrop" id="sidebar-backdrop" onclick="closeSidebar()"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-head">
<span class="sidebar-title">Noten, Dateien &amp; Fehlzeiten</span>
<button class="sidebar-close" onclick="closeSidebar()" aria-label="Schließen"><i data-lucide="x" aria-hidden="true"></i></button>
</div>
<div class="sidebar-body">
<div class="card" id="card-grades" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="graduation-cap" aria-hidden="true"></i> Noten</div>
<button class="add-btn" onclick="openModal('grades')">+ Note</button>
</div>
<div class="card-body" id="list-grades"></div>
</div>
<div class="card" id="card-files" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="folder" aria-hidden="true"></i> Dateispeicher</div>
<div class="card-actions">
<input type="file" id="file-input" style="display:none" multiple onchange="uploadFiles(event)">
<button class="add-btn" onclick="document.getElementById('file-input').click()">↑ Hochladen</button>
</div>
</div>
<div class="card-body" id="files-body">
<div class="upload-drop" id="files-drop">Dateien hier ablegen oder oben hochladen</div>
<div class="sk-line sk"></div>
</div>
</div>
<div class="card" id="card-ab" style="display:none">
<div class="card-head">
<div class="card-title"><i data-lucide="user-x" aria-hidden="true"></i> Fehlzeiten</div>
<button class="add-btn" onclick="openModal('absences')">+ Eintragen</button>
</div>
<div class="card-body" id="list-ab"></div>
</div>
</div>
</aside>
<div id="fs-backdrop" onclick="closeOverlay()"></div>
<footer>
<div class="footer-left">Daten auf <strong>Hetzner-Server, Nürnberg, Deutschland</strong> · EU-DSGVO konform</div>
@@ -1290,27 +1530,39 @@ async function init(){
loadClassEvents();
const r=await fetch('/api/me');
if(r.ok){ const d=await r.json(); loginUI(d.username,d.id,d.role); }
if(r.ok){ const d=await r.json(); loginUI(d.username,d.id,d.role,d.subject); }
}
function loginUI(username,id,role){
function loginUI(username,id,role,subject){
document.getElementById('banner').style.display='none';
document.getElementById('card-tt-public').style.display='none';
document.getElementById('card-tt').style.display='';
document.getElementById('pair-hw-todo').style.display='grid';
document.getElementById('sidebar-btn').style.display='flex';
document.getElementById('card-tickets').style.display='';
['card-hw','card-grades','card-ab','card-todo','card-cd','card-files'].forEach(id=>document.getElementById(id).style.display='');
document.getElementById('sb-label-komm').style.display='';
['sb-nav-grades','sb-nav-files','sb-nav-ab','sb-nav-chat','sb-nav-tickets'].forEach(id=>document.getElementById(id).style.display='flex');
['card-hw','card-todo','card-cd'].forEach(id=>document.getElementById(id).style.display='');
['btn-add-pruefung','btn-add-ferien','btn-add-ql'].forEach(id=>document.getElementById(id).style.display='');
// Class materials and announcements visible to all logged-in users
document.getElementById('sb-label-klasse').style.display='';
['sb-nav-materials','sb-nav-announcements'].forEach(id=>document.getElementById(id).style.display='flex');
// Teacher-only panel
if(role==='teacher'){
document.getElementById('sb-label-lehrer').style.display='';
document.getElementById('sb-nav-teacher').style.display='flex';
}
const adminLink = role === 'admin' ? `<div class="dd-sep"></div><a class="dd-item" href="/admin">🛡️ Admin</a>` : '';
const adminBtn = role === 'admin' ? `<a href="/admin" class="btn-sm" style="background:#fef3c7;color:#92400e;border-color:#fde68a;font-weight:700">🛡️ Admin</a>` : '';
const subjectBadge = (role==='teacher'&&subject) ? `<span class="dd-item meta" style="color:var(--text-muted);font-size:11px">${esc(subject)}</span>` : '';
document.getElementById('h-user').innerHTML=`
${adminBtn}
<div class="avatar" onclick="toggleDropdown(this)">
${esc(username[0].toUpperCase())}
<div class="dropdown" id="user-dropdown">
<div class="dd-item meta">${esc(username)}</div>
${subjectBadge}
<div class="dd-sep"></div>
<span class="dd-item" onclick="openSettings()">⚙️ Einstellungen</span>
${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='<div class="empty">Keine Materialien hochgeladen</div>';return;}
el.innerHTML=mats.map(m=>`<div class="file-item">
<div class="file-icon">${fileIcon(m.mime_type)}</div>
<div class="file-info">
<div class="file-name">${esc(m.title)} <span style="font-size:11px;color:var(--text-muted)">(${esc(m.original_name)})</span></div>
<div class="file-meta">${esc(m.subject)} · ${fmtBytes(m.size)} · ${fmtDate(m.created_at?m.created_at.slice(0,10):'')}</div>
</div>
<button class="file-dl" onclick="window.location.href='/api/teacher/materials/${m.id}/download'">↓ Laden</button>
<button class="del-btn" onclick="delTeacherMaterial(${m.id})">🗑</button>
</div>`).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='<div class="empty">Keine Ankündigungen</div>';return;}
el.innerHTML=anns.map(a=>`<div class="hw-item">
<div class="hw-body">
<div class="hw-title">${esc(a.title)}</div>
<div class="hw-meta">${esc(a.subject)} · ${fmtDate(a.created_at?a.created_at.slice(0,10):'')}</div>
<div style="font-size:12px;color:var(--text-2);margin-top:4px;white-space:pre-wrap;word-break:break-word">${esc(a.content)}</div>
</div>
<button class="del-btn" onclick="delTeacherAnnouncement(${a.id})">🗑</button>
</div>`).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='<div class="empty">Keine Prüfungen angelegt</div>';return;}
el.innerHTML=exams.map(ex=>{
const diff=ex.date?daysUntil(ex.date):null;
return `<div class="ev-item">
<div class="ev-days">${diff===null?'':diff<0?'✓':diff}</div>
<div class="ev-info">
<div class="ev-title">${esc(ex.title)}</div>
<div class="ev-date">${esc(ex.subject)}${ex.date?' · '+fmtDate(ex.date):''}${ex.description?' · '+esc(ex.description):''}</div>
</div>
<button class="ev-del" onclick="delTeacherExam(${ex.id})">🗑</button>
</div>`;
}).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='<div class="empty">Keine Noten vergeben</div>';return;}
el.innerHTML=grades.map(g=>`<div class="grade-row">
<div class="grade-info">
<div class="grade-subj">${esc(g.student_name)}</div>
<div class="grade-type">${GRADE_TYPES[g.type]||g.type}${g.note?' · '+esc(g.note):''}</div>
</div>
<div class="grade-val g${Math.min(Math.round(g.grade),6)}">${g.grade}</div>
<button class="del-btn" onclick="delTeacherGrade(${g.id})">🗑</button>
</div>`).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='<option value="">Schüler auswählen…</option>'+students.map(s=>`<option value="${s.id}">${esc(s.username)}</option>`).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='<div class="empty">Keine Materialien vorhanden</div>';return;}
el.innerHTML=mats.map(m=>`<div class="file-item">
<div class="file-icon">${fileIcon(m.mime_type)}</div>
<div class="file-info">
<div class="file-name">${esc(m.title)}</div>
<div class="file-meta">${esc(m.subject)} · ${esc(m.teacher_name)} · ${fmtBytes(m.size)} · ${fmtDate(m.created_at?m.created_at.slice(0,10):'')}</div>
</div>
<button class="file-dl" onclick="window.location.href='/api/teacher/materials/${m.id}/download'">↓ Laden</button>
</div>`).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='<div class="empty">Keine Ankündigungen</div>';return;}
el.innerHTML=anns.map(a=>`<div class="hw-item">
<div class="hw-body">
<div class="hw-title">${esc(a.title)}</div>
<div class="hw-meta">${esc(a.subject)} · ${esc(a.teacher_name)} · ${fmtDate(a.created_at?a.created_at.slice(0,10):'')}</div>
<div style="font-size:12px;color:var(--text-2);margin-top:4px;white-space:pre-wrap;word-break:break-word">${esc(a.content)}</div>
</div>
</div>`).join('');
}
// ── START ─────────────────────────────────────────────────────
init();
</script>
+22 -5
View File
@@ -212,6 +212,7 @@ footer a:hover { color: #2563eb; }
<div class="field">
<label for="l-user">Benutzername</label>
<input type="text" id="l-user" autocomplete="username" placeholder="dein.name" required>
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Der Teil deiner Schul-E-Mail vor dem @</span>
</div>
<div class="field">
<label for="l-pass">Passwort</label>
@@ -232,9 +233,22 @@ footer a:hover { color: #2563eb; }
<div class="notice notice-amber" id="teacher-notice">
<i data-lucide="info" aria-hidden="true"></i> Lehrerkonten werden nach der Registrierung von einem Administrator geprüft und freigeschaltet.
</div>
<div class="field" id="subject-field" style="display:none">
<label for="r-subject">Lehrfach</label>
<select id="r-subject" style="padding:9px 12px;border:1px solid #e5e7eb;border-radius:7px;font-size:14px;font-family:inherit;color:#111827;background:#fff;outline:none;transition:border-color .12s">
<option value=""> Fach auswählen </option>
<option>Deutsch</option><option>Mathematik</option><option>Englisch</option>
<option>Informatik</option><option>Wirtschaft</option><option>Buchführung</option>
<option>BWL</option><option>VWL</option><option>Recht</option>
<option>Rechnungswesen</option><option>Sport</option><option>Religion</option>
<option>Geschichte</option><option>Gemeinschaftskunde</option><option>Physik</option>
<option>Chemie</option><option>Biologie</option><option>Sozialkunde</option>
<option>Ethik</option><option>Sonstiges</option>
</select>
</div>
<div class="field">
<label for="r-email">Schul E-Mail</label>
<input type="email" id="r-email" autocomplete="email" placeholder="vorname.nachname@ifb-schulen.com" required>
<input type="email" id="r-email" autocomplete="email" placeholder="Schul E-Mail-Adresse" required>
</div>
<div class="field">
<label for="r-pass">Passwort</label>
@@ -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 r = await fetch('/api/register', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
const body = {
email: document.getElementById('r-email').value,
password: document.getElementById('r-pass').value,
role: selectedRole
}) });
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(body) });
const d = await r.json();
if (!r.ok) { showErr('reg-err', d.error); return; }
if (d.pending) {
+1 -1
View File
@@ -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) {
+53
View File
@@ -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;
+15 -6
View File
@@ -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) => {
+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;