Schüler-/Eltern-Portal
Öffentlicher Bereich (app/(portal)/), über den Erziehungsberechtigte/Schüler Informationen zu zugeordneten Schülern einsehen. Das Portal besitzt ein vollständig eigenes Auth- und Session-System, das strikt vom NextAuth-Admin-Login getrennt ist.
Das Portal-Login ist nicht NextAuth. Es nutzt eine eigene User-Tabelle (PortalUser), eigene Session-Tokens (PortalSession) und ein eigenes Cookie (portal_session). Admin-Session (auth()) und Portal-Session (getSession()) dürfen nicht verwechselt werden. Änderungen an diesem System sollten besonders sorgfältig geprüft werden.
Trennung vom NextAuth-Admin-Login
| Aspekt | Admin (Backend) | Portal |
|---|---|---|
| User-Modell | User | PortalUser |
| Session | NextAuth (JWT), auth() | DB-Session PortalSession, getSession() |
| Cookie | NextAuth-Cookie | portal_session (eigenes Cookie) |
| Login-Endpoint | NextAuth | POST /api/portal/auth/login |
| Passwort-Hash | NextAuth/Credentials | bcrypt (Cost-Faktor 12) in lib/portal/auth.ts |
| Schutz | proxy.ts über auth() | proxy.ts über getSession() für /portal/* |
Beide Systeme laufen parallel in derselben Applikation, teilen aber keinen Auth-State. Die Admin-Layout-Routen (/dashboard, /students, …) prüfen die NextAuth-Session; /portal/* prüft ausschließlich die Portal-Session.
Datenmodell
PortalUser
model PortalUser {
id String @id @default(uuid())
email String @unique
passwordHash String
role PortalRole
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
guardianLinks GuardianPortalLink[]
consents PortalConsent[]
sessions PortalSession[]
}
PortalRole: PARENT | STUDENT | GUARDIAN.
PortalUser besitzt kein tenant_id-Feld. email ist global @unique (nicht pro Mandant). Der Mandanten-Bezug ergibt sich erst transitiv über GuardianPortalLink → Guardian → Student/Tenant. Dies ist beim Aufbau weiterer Portal-Queries zu beachten — eine Isolation muss über die Guardian-Verknüpfung sichergestellt werden.
GuardianPortalLink
Verknüpft einen PortalUser mit einem Guardian (Erziehungsberechtigten eines Schülers).
model GuardianPortalLink {
id String @id @default(uuid())
portalUserId String
guardianId String
accessLevel AccessLevel @default(STANDARD)
isActive Boolean @default(true)
createdBy String // Admin-User-ID
createdAt DateTime @default(now())
@@unique([portalUserId, guardianId])
}
AccessLevel: LIMITED (nur Einsicht, keine Aktionen) | STANDARD (Einsicht + Formulare/Aktionen) | FULL (zusätzlich sensible Daten; in Phase 2 funktional wie STANDARD, Prüfstelle existiert bereits). Über isActive lässt sich ein einzelner Zugang deaktivieren, ohne den User zu löschen. Das Level gilt pro Verknüpfung (also pro Kind), nicht pro PortalUser — ein Elternteil kann für Kind A STANDARD und für Kind B LIMITED haben. Durchgesetzt wird es über requireAction() in lib/portal/access.ts (siehe unten); die Admin-UI zum Ändern sitzt in components/ui/students/student-guardians-card.tsx.
PortalSession
model PortalSession {
id String @id @default(uuid())
userId String
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
lastUsedAt DateTime @default(now())
}
Server-seitige Session-Persistenz. Der Token ist ein crypto.randomBytes(32).toString('hex')-Wert und wird im portal_session-Cookie abgelegt.
PortalConsent
DSGVO-Einwilligungen. ConsentType: DATA_PROCESSING | COMMUNICATION | DOCUMENT_STORAGE | EMAIL_NOTIFICATIONS. Pro Einwilligung werden granted, grantedAt, sowie ipAddress und userAgent als Nachweis dokumentiert. Einträge werden nie überschrieben, jede Änderung legt eine neue Zeile an.
Bei EMAIL_NOTIFICATIONS hält notificationTypes (PortalNotificationType[]) zusätzlich fest, welche Anlässe zum Zeitpunkt des Eintrags aktiv waren. PortalNotificationPreference bleibt die Quelle für den aktuellen Stand (Versandentscheidung), der Snapshot dient nur dem Nachweis und der Anzeige im Profil. Einträge von vor Einführung der Spalte haben ein leeres Array.
PortalInvitation
model PortalInvitation {
id String @id @default(uuid())
email String
token String @unique
guardianId String
accessLevel AccessLevel @default(STANDARD)
expiresAt DateTime
usedAt DateTime?
createdBy String // Admin-User-ID
createdAt DateTime @default(now())
}
Einladungs-Token, das ein Admin pro Guardian erzeugt. Aktivierung verbraucht das Token (usedAt) und ist nach expiresAt ungültig. accessLevel wird beim Versenden der Einladung vom Admin gewählt und bei der Aktivierung 1:1 auf den neu angelegten GuardianPortalLink übertragen.
PortalPasswordReset
model PortalPasswordReset {
id String @id @default(uuid())
portalUserId String
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
}
Reset-Token für „Passwort vergessen". Nur der SHA-256-Hash des Tokens wird gespeichert (lib/portal/password-reset.ts), nicht der Token selbst — anders als bei PortalInvitation/PortalSession, die den Token im Klartext speichern (dort unkritisch, da sie keinen Zugriff auf ein bestehendes Passwort geben). Gültigkeit 1 Stunde, einmalig nutzbar (usedAt). Ein erfolgreicher Reset löscht in derselben Transaktion alle PortalSessions des Nutzers.
Auth — lib/portal/auth.ts
| Funktion | Zweck |
|---|---|
hashPassword(password) | bcrypt.hash(password, 12) |
verifyPassword(password, hash) | bcrypt.compare |
authenticateUser(email, password) | Login-Prüfung |
authenticateUser normalisiert die E-Mail auf toLowerCase(), lehnt deaktivierte Accounts (isActive === false) ab, prüft das Passwort und aktualisiert bei Erfolg lastLoginAt. Fehlermeldungen für „kein User" und „falsches Passwort" sind identisch (Ungültige E-Mail oder Passwort) — kein User-Enumeration-Hint.
Session — lib/portal/session.ts
| Funktion | Zweck |
|---|---|
createSession(userId) | Erzeugt 32-Byte-Token, legt PortalSession an (Laufzeit 7 Tage) und setzt das portal_session-Cookie. |
getSession() | Liest das Cookie, lädt die Session inkl. aktiver guardianLinks und der zugehörigen Schüler/Klassen. |
destroySession() | Löscht die Session-Zeile (best-effort) und das Cookie. |
verifyStudentAccess(userId, studentId) | Prüft, ob ein aktiver GuardianPortalLink des Users auf den Schüler zeigt. |
Cookie-Eigenschaften in createSession:
cookieStore.set('portal_session', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
expires: expiresAt, // now + 7 Tage
path: '/'
});
getSession gibt null zurück, wenn:
- kein Cookie/Token vorhanden ist,
- keine passende
PortalSessionexistiert oderexpiresAt < new Date()(abgelaufen), - der zugehörige
PortalUserisActive === falseist (Deaktivierung wirkt sofort, nicht erst beim nächsten Login).
Bei gültiger Session wird lastUsedAt aktualisiert und ein PortalSession-DTO mit userId, email, role und der Liste zugeordneter Schüler (aus aktiven guardianLinks) zurückgegeben.
Zugriffsprüfung — lib/portal/access.ts
proxy.ts schützt nur Seiten (/portal/*), nicht Server Actions oder API-Routen. Jede Portal-Datenfunktion (Stundenplan, Fehlzeiten, Entschuldigungen, Dateien, …) muss deshalb selbst über requireStudentAccess/requireAction gehen — es gibt keine automatische Durchsetzung auf Route-Ebene.
| Funktion | Zweck |
|---|---|
requirePortalSession() | Liefert die PortalSession oder redirect('/portal/login'). Funktioniert sowohl in Server Components als auch in Server Actions (Next löst redirect() in beiden Fällen auf), nicht in Route Handlers (app/api/**/route.ts) — dort weiterhin direkt getSession() verwenden und manuell 401 zurückgeben (siehe bestehende /api/portal/auth/*-Routen). |
hasStudentAccess(userId, studentId) | Reine Boolean-Prüfung ohne Redirect/Throw — ausschließlich für verifyStudentAccess in session.ts (das proxy.ts aufruft, bevor eine volle Session geladen wird). |
requireStudentAccess(studentId, { minLevel? }) | Prüft einen aktiven GuardianPortalLink auf den Schüler, wirft PortalAccessError bei fremdem/deaktiviertem Zugang oder zu niedrigem minLevel. Liefert { session, student, tenantId, accessLevel } — tenantId kommt aus student.tenant_id, nie aus Client-Eingaben. |
requireAction(studentId) | Wie oben mit minLevel: 'STANDARD' — lehnt LIMITED-Zugänge ab (Fehlermeldung „Ihr Zugang erlaubt nur Einsicht."). Für alle schreibenden Portal-Aktionen. |
session.tss verifyStudentAccess delegiert intern an hasStudentAccess, um die Query nicht doppelt zu pflegen — es besteht ein bewusster zirkulärer Modul-Import zwischen access.ts und session.ts (beide Seiten nutzen die importierte Funktion nur innerhalb von Funktionsrümpfen, nicht beim Modul-Laden, was in ESM unproblematisch ist).
DTO-Regel
Portal-Datenfunktionen liefern nie rohe Prisma-Objekte an Client-Komponenten. requireStudentAccess selbst gibt den Student (inkl. school_class/teacher) nur serverseitig zurück; jede Funktion, die daraus Daten für eine Client-Komponente ableitet (Stundenplan-DTO, Fehlzeiten-DTO, …), baut ein eigenes, minimales DTO ohne interne Felder (Lehrkraft-Notizen, Abwesenheitsgründe, Konfliktprüfungs-Details). Siehe docs/TASK-portal-phase-2.md, Abschnitt „Sicherheits-Checkliste".
Passwort-Reset — lib/portal/password-reset.ts
| Funktion | Zweck |
|---|---|
requestPasswordReset(email) | Löst „Passwort vergessen" aus. Liefert kein Signal, ob die Adresse existiert, der Account aktiv ist oder die Drosselung gegriffen hat — der aufrufende Route Handler (/api/portal/auth/forgot-password) antwortet in jedem Fall identisch. Drosselung: max. 3 offene (unbenutzte, nicht abgelaufene) Resets pro Nutzer und Stunde — einfache DB-Zählung, Übergangslösung, bis es projektweites Rate-Limiting gibt (schützt nicht vor verteilten Angriffen über viele Adressen). |
isResetTokenValid(token) | Für die Server-Component-Vorprüfung auf /portal/reset-password/[token] (zeigt „Link ungültig" statt eines toten Formulars). |
resetPassword(token, newPassword) | Prüft Token-Hash + Gültigkeit + „noch nicht benutzt", setzt das Passwort und löscht alle PortalSessions des Nutzers — in derselben Transaktion. |
Stundenplan — lib/portal/timetable.ts
Die Kernquery sitzt in loadClassWeek(tenantId, classId, weekStart)
(lib/timetable/timetable-overview.ts) — session-frei, von der
Mitarbeiter-Funktion getWeeklyTimetableByClassId (bindet nur noch
auth() → tenantId und ruft loadClassWeek auf) und der
Portal-Funktion getPortalWeekForStudent gemeinsam genutzt. Portal und
Schule zeigen dadurch garantiert denselben Plan; die Ist-Auflösung
(resolveCellExceptions/buildMovedInIndex aus
lesson-exception-resolve.ts) läuft ebenfalls identisch.
| Funktion | Zweck |
|---|---|
getPortalWeekForStudent(studentId, weekStart) | Über requireStudentAccess — Mandant/Klasse kommen aus dem Kind. Liefert PortalWeek (entries, lessonTimes, hasClass). Kind ohne Klasse → hasClass: false, leere Liste. |
getPortalTodaySummary(studentId) | Für die Dashboard-Karte „Heute": heutige Stunden + alle Änderungen (status !== 'normal') im Zeitraum upcomingSchoolWindow() (wie der Digest: nächster Schultag bis Freitag, ab Freitag die nächste Woche). Liegt der Zeitraum in der Folgewoche, wird diese zusätzlich abgefragt (upcomingIsNextWeek). |
Portal-DTO (PortalTimetableEntry)
Enthält nur: subjectName, teacherName (Name, kein email-Fallback nur intern), roomName, period/startTime/endTime, status und typeLabel (die konfigurierbare LessonExceptionType.name, z. B. „Vertretung"). Keine note-Felder, keine Abwesenheitsgründe, keine Konfliktprüfungs-Details — siehe DTO-Regel oben.
status ist eine von normal | entfaellt | vertreten | verlegt | raumaenderung, abgeleitet aus derselben Vorrang-Logik wie ClassEntryCell/ClassWeekGrid (Mitarbeiter-Ansicht): cancelled > Lehrkraft-Override (vertreten) > Raum-Override (raumaenderung) > sonstige geplante Änderung (vertreten) > normal. Eine verlegte Stunde (kind: 'moved') erscheint nicht an ihrer ursprünglichen Zelle, sondern nur an ihrem Ziel mit Status verlegt — analog zu MovedInCell in ClassWeekGrid.tsx. Die Uhrzeit am Ziel kommt aus der Ziel-Unterrichtszeit (lessonTimeByNumber), nicht aus der Quellstunde.
Wochenparameter
Abweichend vom in der ursprünglichen Task-Beschreibung genannten week=YYYY-Www-Format nutzt die Portal-Seite denselben week=YYYY-MM-DD-Parameter wie die Mitarbeiter-Stundenplanansicht (parseWeekParam/addWeeks/formatWeekRange aus lib/timetable/week-utils.ts) — ein Datum innerhalb der Woche statt einer ISO-Wochennummer, um keine zweite Konvention einzuführen.
E-Mail — lib/portal/email.ts
sendInvitationEmail(data) verschickt die Einladung per nodemailer (SMTP-Konfiguration aus SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASSWORD, secure bei Port 465). Der Aktivierungslink zeigt auf ${NEXT_PUBLIC_URL}/portal/activate/${token}; im Text wird auf 7 Tage Gültigkeit hingewiesen.
sendPasswordResetEmail(data) verschickt denselben Transport für den Reset-Link (${NEXT_PUBLIC_URL}/portal/reset-password/${token}), Hinweis auf 1 Stunde Gültigkeit und einmalige Nutzbarkeit.
API-Routen — app/api/portal/*
| Route | Methode | Zweck |
|---|---|---|
/api/portal/auth/login | POST | Validiert email/password, ruft authenticateUser + createSession. 401 bei ungültigen Daten. |
/api/portal/auth/logout | POST | destroySession(). |
/api/portal/auth/session | GET | Liefert { authenticated, user } aus getSession(); 401 wenn keine Session. |
/api/portal/auth/change-password | POST | Erfordert bestehende Session (getSession(), nicht requirePortalSession, da Route Handler). Prüft aktuelles Passwort über changePassword (lib/portal/auth.ts), neues Passwort mind. 8 Zeichen. |
/api/portal/auth/forgot-password | POST | Ruft requestPasswordReset; antwortet immer identisch (auch bei internen Fehlern), unabhängig vom Ergebnis. |
/api/portal/auth/reset-password | POST | Ruft resetPassword; validiert Passwortlänge vor dem Aufruf. |
/api/portal/activate | POST | Account-Aktivierung über Einladungs-Token. |
/api/admin/portal/invite | POST | Admin-seitig (NextAuth-auth() + hasPermission(students, write)). Nimmt zusätzlich accessLevel entgegen (Default STANDARD bei ungültigem/fehlendem Wert). |
Aktivierungs-Flow (/api/portal/activate)
tokenundpassworderforderlich; Passwort mindestens 8 Zeichen.acceptedTerms === trueist Pflicht (DSGVO-Einwilligung), sonst 400.PortalInvitationper Token laden. Abbruch bei: nicht gefunden (404), bereits verwendet (usedAt), abgelaufen (expiresAt < now).PortalUsermit gehashtem Passwort undrole: 'PARENT'anlegen.GuardianPortalLinkmitaccessLevelaus der Einladung (invitation.accessLevel) undcreatedByaus der Einladung anlegen.- Zwei
PortalConsent-Einträge (DATA_PROCESSING,COMMUNICATION) mitgranted,ipAddress(x-forwarded-for) unduserAgentals Nachweis erstellen. - Einladung über
usedAtals verbraucht markieren.
Schutzlogik in proxy.ts
Die Middleware behandelt den Portal-Bereich separat vom NextAuth-Bereich:
if (path.startsWith('/portal')) {
// Öffentlich: /portal, /portal/login, /portal/activate/*,
// /portal/forgot-password, /portal/reset-password/*
if (path === '/portal' || path === '/portal/login'
|| path.startsWith('/portal/activate/')
|| path === '/portal/forgot-password'
|| path.startsWith('/portal/reset-password/')) {
return nextWithPathname();
}
const session = await getSession(); // Portal-Session, nicht auth()
if (!session) {
return NextResponse.redirect(new URL('/portal/login', req.url));
}
// Schüler-spezifische Seiten: Zugriff prüfen
const studentMatch = path.match(/\/portal\/student\/([^/]+)/);
if (studentMatch) {
const hasAccess = await verifyStudentAccess(session.userId, studentMatch[1]);
if (!hasAccess) {
return NextResponse.redirect(new URL('/portal/dashboard', req.url));
}
}
}
Wichtige Punkte:
- Geschützte Portal-Seiten erfordern eine gültige
getSession(); ohne Session erfolgt Redirect auf/portal/login. - Auf
/portal/student/<studentId>wird zusätzlich überverifyStudentAccessgeprüft, dass der Portal-User tatsächlich (via aktivemGuardianPortalLink) Zugriff auf diesen Schüler hat — Schutz gegen ID-Enumeration. Kein Zugriff → Redirect/portal/dashboard. - Der
config.matcherder Middleware schließtapiaus ('/((?!api|_next/static|_next/image).*)'). Die/api/portal/*-Routen laufen daher nicht durch diese Schutzlogik, sondern sichern sich selbst übergetSession()bzw.authenticateUser.
Routen — app/(portal)/
| Route | Inhalt |
|---|---|
(portal)/layout.tsx | Lädt getSession(); rendert PortalNavigation nur bei vorhandener Session. |
(portal)/portal/login/page.tsx | Login-Seite (öffentlich). |
(portal)/portal/activate/[token]/page.tsx | Aktivierungsseite (öffentlich). |
(portal)/portal/dashboard/page.tsx | Übersicht der zugeordneten Schüler; Redirect auf /portal/login ohne Session. |
(portal)/portal/forgot-password/page.tsx | „Passwort vergessen"-Formular (öffentlich). |
(portal)/portal/reset-password/[token]/page.tsx | Neues Passwort vergeben; prüft Token serverseitig über isResetTokenValid vor dem Rendern des Formulars (öffentlich). |
(portal)/portal/profile/page.tsx | E-Mail, verknüpfte Kinder + accessLevel, Passwort ändern (ChangePasswordForm), PortalConsent-Übersicht. |
(portal)/portal/student/[studentId]/page.tsx | Kopf (Name, Klasse, Klassenlehrkraft), Schnell-Krankmeldung-Button, Kacheln für Stundenplan/Vertretungen (/timetable) und Fehlzeiten (/attendance). Zugriff über requireStudentAccess, notFound() bei PortalAccessError. |
(portal)/portal/student/[studentId]/timetable/page.tsx | Wochenplan des Kindes; liest ?week= (Server Component, erster Render), Nachladen bei Wochennavigation über die Server Action fetchPortalWeek (lib/portal/timetable-actions.ts). |
(portal)/portal/student/[studentId]/attendance/page.tsx | Fehlzeiten-Übersicht + Formular „Entschuldigung einreichen" + Liste eigener eingereichter Entschuldigungen (Zurückziehen solange pending). |
Fehlzeiten — lib/portal/attendance.ts
| Funktion | Zweck |
|---|---|
getPortalAttendanceForStudent(studentId, range?) | DTO ohne Attendance.notes (interne Lehrkraft-Vermerke). Default-Zeitraum: laufendes Schuljahr (SchoolYear.is_current) bis heute — Abweichung vom Task-Dokument ("laufendes Halbjahr"): das Schema kennt kein Halbjahres-Konzept. Verknüpft passende AbsenceNote über den Datumsbereich, nicht über attendance_id (siehe unten). |
getPortalAbsenceNotesForStudent(studentId) | Eigene eingereichte Entschuldigungen, unabhängig davon, ob dafür schon ein Attendance-Eintrag existiert (z. B. zukünftiger Zeitraum). reviewNotes nur bei rejected befüllt. |
Entschuldigungen einreichen — lib/portal/absence-note.ts
'use server'-Datei (jede Exportfunktion ist eine Server Action):
| Funktion | Zweck |
|---|---|
submitAbsenceNoteAction(studentId, formData) | requireAction (lehnt LIMITED ab). Fristen: max. 14 Tage Zukunft / 30 Tage rückwirkend. Prüft ClassbookSettings.allow_parent_notes (pro Tenant) und Überschneidung mit bestehender pending/accepted-Note — siehe Infobox unten zur Ausnahme bei vollständig abgedeckten Notes. Optionales Attest im selben FormData-Feld file (Server Actions unterstützen File direkt in FormData — kein separater Upload-Request nötig). Auditiert mit portalActor(). |
submitQuickSickReportAction(studentId) | Feste Werte (heute, "Krankheit", quick_report: true), kein Attest. Legt keine Attendance-Zeile an — Attendance.recorded_by_id ist NOT NULL mit FK auf User, ein PortalUser kann das Feld nicht füllen. Die Verknüpfung entsteht erst, wenn die Lehrkraft die Stunde erfasst (AttendanceSource.parent_note, siehe unten). |
withdrawAbsenceNoteAction(studentId, absenceNoteId) | Nur solange status: 'pending'. Bearbeiten ist bewusst nicht umgesetzt — Zurückziehen + Neu-Einreichen deckt denselben Fall ab, ohne eine zweite Formular-Fläche zu brauchen. |
attendance_id wird bei der Einreichung nicht gesetztDer Task-Vorschlag ("Ist bereits eine passende Attendance vorhanden, attendance_id verknüpfen") wurde bewusst nicht umgesetzt: Attendance ist eindeutig pro [timetable_id, student_id, date] — ein Tag kann mehrere Unterrichtsstunden haben, AbsenceNote.attendance_id ist aber @unique und kann nur eine einzige Zeile referenzieren. Eine „passende" Zeile für einen Tag mit mehreren Stunden auszuwählen wäre willkürlich. reviewAbsenceNote (siehe unten) löst Attendance-Zeilen deshalb durchgängig über den Datumsbereich auf, nie über attendance_id.
Eine bestehende pending/accepted-Note, deren Zeitraum vollständig im neuen Zeitraum enthalten ist, blockiert assertCanSubmit nicht — Praxisfall: Schnell-Krankmeldung für heute, später gefolgt von einer regulären Entschuldigung für die ganze Woche (die den bereits gemeldeten Tag mit einschließt). Ist die alte Note noch pending, wird sie beim Anlegen der neuen Note automatisch zurückgezogen (supersedePendingNotes, gleiche Transaktion, auditiert); eine bereits accepted-Note bleibt unangetastet — der Tag ist dadurch schon korrekt entschuldigt, reviewAbsenceNote würde die zugehörige Attendance-Zeile beim späteren Annehmen der neuen Note ohnehin nur dann anfassen, wenn ihr Status noch absent/absent_unexcused ist. Nur eine Überlappung, die nicht vollständig aufgeht (z. B. zwei sich nur teilweise überschneidende Zeiträume), gilt weiterhin als Konflikt.
Entschuldigungen prüfen — lib/actions/klassenbuch/absence-note.ts + lib/klassenbuch/permissions.ts
reviewAbsenceNote existierte bereits, wurde aber nirgends aus der UI aufgerufen — AbsenceNoteBadge war rein informativ (Tooltip, kein Annehmen/Ablehnen). Auch tasks/todo.md führte "Entschuldigung-annehmen/ablehnen-UI" unabhängig als bekannte Lücke. Zusätzlich prüfte reviewAbsenceNote bis dahin nur "eingeloggt + gleicher Mandant" — jede Mitarbeiterin/jeder Mitarbeiter konnte jede Entschuldigung im Mandanten bearbeiten.
canReviewAbsenceNote({ userId, studentId, tenantId, userRole, hasClassbookWritePermission })—ADMINoderklassenbuch:writeimmer erlaubt, sonst nur die Klassenlehrkraft der aktuellen Klasse des Schülers (Student.school_class.teacher_id). Analog zucanRecordAttendance.reviewAbsenceNoteruft diese Prüfung jetzt vor dem Update auf.- Bei Annahme: alle
Attendance-Zeilen des Schülers im Zeitraumdate_from..date_tomit Statusabsent/absent_unexcusedwerden aufabsent_excusedgesetzt (source: 'parent_note') — nicht mehr nur eine einzelneattendance_id.late/left_early/present/not_checked-Zeilen bleiben unverändert, damit keine Teilzeit-Information verloren geht. - UI:
components/klassenbuch/PendingAbsenceNotesPanel.tsx, gerendert inClassbookLayout.tsxoberhalb des 3-Spalten-Grids (nicht an eine einzelne Stunde gebunden), sichtbar für die Klassenlehrkraft oder beiklassenbuch:write.
Attest-Upload — Schema-Erweiterung + eigener Zugriffspfad
lib/file/filePermissionService.ts kannte ursprünglich ausschließlich Backend-User (UserRole-Prüfung), File.uploadedById war NOT NULL mit FK auf User — ein PortalUser konnte ohne Schema-Änderung keine Datei hochladen.
- Schema:
File.uploadedByIdist jetzt optional, neues optionalesuploadedByPortalUserId(FK aufPortalUser) — anwendungsseitig ist immer genau eines der beiden Felder gesetzt (lib/file/fileUploadService.ts).FileAccessLoganalog umportalUserIdergänzt. NeueFileCategory.ABSENCE_ATTESTmit eigenem Löschzyklus (siehe unten) statt fixer Jahres-Retention. - Upload:
uploadFile()nimmt jetztuserIdoderportalUserIdentgegen;submitAbsenceNoteActionruft es mitportalUserIdauf, KategorieABSENCE_ATTEST, VisibilityPRIVATE. - Download (Eltern):
/api/portal/absence-notes/[absenceNoteId]/attestation(GET) — eigene, enge Route (Portal-Session +verifyStudentAccess+ die Datei muss überAbsenceNote.file_idzu dieser Note gehören). Bewusst nachabsenceNoteIdbenannt, nichtfileId, damit keine fremde Datei-ID erraten werden kann. - Download (Schule): läuft über die normale
/api/files/[id]/download-Route.checkFilePermissionhat dafür einen neuen, engen Pfad: Beicategory === 'ABSENCE_ATTEST'greift nicht die generischeFileVisibility-Logik, sondern ausschließlich die Klassenlehrkraft der aktuellen Klasse des betroffenen Schülers (über die verknüpfteAbsenceNote) darf zugreifen. - Löschung:
app/api/cron/delete-reviewed-absence-attestations/route.ts(invercel.jsonregistriert) löscht Atteste erstgetAbsenceAttestDeleteAfterDecisionDays()Tage nach der Entscheidung (AbsenceNote.reviewed_at) über die Entschuldigung — nicht nach einer festen Upload-Retention wie die übrigenFileCategory-Werte, da es sich um Gesundheitsdaten handelt (so kurz wie möglich aufbewahren).
E-Mail-Benachrichtigungen (Outbox) — lib/portal/notifications.ts + lib/mail/
Siehe docs/TASK-portal-email-benachrichtigung.md für die ursprüngliche Aufgabenstellung.
- Gemeinsamer Transporter:
lib/mail/transport.ts(mailTransporter) — löst die vorherige Dreifach-Duplizierung des nodemailer-Transporters ab.lib/email.ts,lib/portal/email.tsundlib/user/invitation.tsnutzen ihn, behalten aber jeweils ihr eigenesSMTP_FROM-Fallback. - Outbox statt Direktversand:
PortalNotification(type,payload,dedupeKeyeindeutig proportalUserId,readAt,emailSentAt,emailError,emailAttempts,emailSkippedAt) speist sowohl die Dashboard-Karte „Neu für Sie" als auch den Mail-Versand — beide können nie auseinanderlaufen, weil es nur eine Quelle gibt. - Opt-in getrennt von
COMMUNICATION: neuerConsentType.EMAIL_NOTIFICATIONS, eigenständig und freiwillig, nicht aus der Sammel-Checkbox der Aktivierung abgeleitet (app/api/portal/activate/route.tssetzt dort weiterhin nurDATA_PROCESSING/COMMUNICATION). ZusätzlichPortalNotificationPreferencepro Typ (emailEnabled, Defaultfalse). Versand nur wenn beides zutrifft — siehelib/portal/notification-preferences.ts::isEmailNotificationEnabled. - Ereignisse erzeugen (
lib/portal/notifications.ts::createPortalNotifications): lädt die berechtigtenPortalUsereines Schülers (aktiverGuardianPortalLink+ aktiverPortalUser,LIMITEDeingeschlossen) und legt percreateMany({ skipDuplicates: true })einen Eintrag je Nutzer an. Wirft nie — ein Fehler hier darf die auslösende Fachaktion nicht scheitern lassen. Aufrufer:reviewAbsenceNote(lib/actions/klassenbuch/absence-note.ts), nach der Transaktion, zusätzlich in ein eigenes try/catch gewrappt (doppelte Absicherung).dedupeKey = absence-note:<id>:<status>./api/cron/create-timetable-digest(lib/portal/timetable-digest.ts::createTimetableDigestNotifications), täglich: lädt veröffentlichteLessonExceptions im FensterupcomingSchoolWindow()(lib/portal/school-week.ts, auch von der Dashboard-Karte „Heute" genutzt) — vom nächsten Schultag bis zum Freitag derselben Woche (Mo–Do also Rest der laufenden Woche, ab Freitag die komplette nächste Woche; keine Feiertagsprüfung, im Schema aktuell kein Ferienkalender). Gruppiert nach Klasse, bildet pro Vertretung einen Standhash(id:datum:kind:substitution_type_id:substitute:room:subject). Weil sich das Fenster täglich verschiebt, wird nicht der Gesamtstand verglichen (sonst käme jeden Tag ein neuer Digest), sondern pro Schüler und Vertretung gegen den zuletzt gemeldeten Stand aus früheren Digests (letzte 21 Tage, chronologisch zusammengeführt,payload.exceptionStates: { [exceptionId]: stand }). Dadurch wird auch eine Rückänderung A → B → A erneut gemeldet. Nur bei mindestens einer neuen oder geänderten Vertretung entsteht ein Eintrag, Payload{ dateIso, changedDates, details, exceptionStates, exceptionSnapshots }.detailsenthält je neuer/geänderter Vertretung einen Schnappschuss (TimetableChangeSnapshot: Tag, Stunde, Fach, Typ, Vertretungslehrkraft, Ersatzraum) und bei Änderungen zusätzlichprevious(zuletzt gemeldeter Stand ausexceptionSnapshots). Die Karte „Neu für Sie" zeigt daraus die Details und einen Vorher/Nachher-Vergleich; die Mail nutzt diese Daten nie.dedupeKey = timetable-digest:<studentId>:<fensterstart>:<hash(zeitpunkt der letzten Meldung + neue Stände)>schützt gegen doppelten Cron-Lauf, ohne eine spätere Rückänderung zu verschlucken.notificationsQueuedzählt tatsächlich angelegte Zeilen (Rückgabewert voncreatePortalNotifications). Zurückgenommene Vertretungen (gelöscht/unveröffentlicht) lösen keine Benachrichtigung aus.
- Mailinhalt (
lib/portal/notification-mail.ts::renderNotificationMail): bewusst minimal — nur Anlass und Vorname des Kindes, nie Gründe/Diagnosen/Stundendetails. Snapshot-Test inlib/portal/__tests__/notification-mail.test.ts. - Versand (
/api/cron/send-portal-notifications→lib/portal/notification-dispatch.ts::dispatchPendingPortalNotifications): lädt offene Einträge (emailSentAt: null,emailSkippedAt: null,emailAttempts < 3), prüft Aktivstatus (Portal-Nutzer, Kind) und Opt-in — fehlt eines davon, wirdemailSkippedAtgesetzt und der Eintrag endgültig nicht mehr versendet (kein Nachversand nach späterem Opt-in; die Karte „Neu für Sie" zeigt ihn weiterhin, und übersprungene Einträge können die 50er-Batches nachfolgender Läufe nicht blockieren) — claimt den Eintrag dann atomar (updateMany({ where: { id, emailSentAt: null } })— nur wercount === 1gewinnt, versendet) und erst danachsendMail. Schlägt der Versand fehl, wird der Claim zurückgesetzt undemailAttemptserhöht. Dieses Claim-vor-Versand-Muster macht den Cron idempotent gegen doppelten/überlappenden Lauf, ohne eine DB-Transaktion über den eigentlichen Mailversand zu benötigen. - Audit:
PortalConsentist neu auf der Audit-Allowlist (lib/audit/allowlist.ts) — Einwilligungsänderungen (Aktivierung und das neue Profil-Formular) werden auditiert.PortalNotification/PortalNotificationPreferencesind bewusst nicht auditiert (reine Zustellmechanik/Präferenz), siehelib/audit/__tests__/allowlist.test.ts. - Dashboard-Karte „Neu für Sie" (
components/portal/NewForYouCard.tsx): zeigtgetUnreadNotifications(), Klick ruft/api/portal/notifications/[id]/readauf (markNotificationRead— nur eigene Einträge,portalUserIdaus der Session) und navigiert zur passenden Unterseite. Anders als die Mail zeigt die Karte das Ergebnis (angenommen/abgelehnt) und den Zeitraum bzw. das Datum, weil sie nur angemeldeten Nutzern angezeigt wird. Die Werte kommen aus dem Payload (ABSENCE_NOTE_DECIDED:absenceNoteId,status,dateFromIso,dateToIso;TIMETABLE_CHANGED_DIGEST:dateIso). Ältere Einträge ohne diese Felder fallen auf den neutralen Text zurück. Das Ziel wird per Query-Parameter hervorgehoben:attendance?note=<id>bzw.timetable?week=<Montag>&day=<Datum>. Das ✕ markiert einen Eintrag als gelesen, ohne zu navigieren. - Offene Platzhalter (siehe Abschlussbericht der Umsetzung): Digest-Zeitpunkt (
vercel.json, aktuell 17:00 UTC ≈ 18–19 Uhr je nach Sommer-/Winterzeit), Versand-Zeitpunkt (aktuell einmal täglich um 17:30 UTC, kurz nach der Digest-Erzeugung — der Hobby-Plan erlaubt pro Cron nur eine tägliche Ausführung; eine häufigere Zustellung bräuchte einen Plan-Wechsel), Absender/Impressum, Formulierung der Einwilligung/Datenschutztext.
Mitteilungen
Elternbriefe im Portal (/portal/mitteilungen), siehe Elternbriefe. PortalNotification.studentId ist dafür optional: ANNOUNCEMENT und ANNOUNCEMENT_REMINDER gelten je Elternteil, die betroffenen Kinder stehen im payload.
Verwandte Stellen
components/portal/PortalNavigation.tsx— Portal-Navigation, inkl. Kind-Wechsler bei mehreren Kindern.components/portal/StudentCard.tsx— Schüler-Karte im Dashboard (verlinkt nur auf existierende Seiten).components/portal/ChangePasswordForm.tsx/ResetPasswordForm.tsx— Client-Formulare für Passwort ändern/zurücksetzen.lib/portal/auth.ts/session.ts/access.ts/password-reset.ts/timetable.ts/email.ts— Auth, Session, Zugriffsprüfung, Passwort-Reset, Stundenplan, E-Mails.components/portal/StudentTimetableWeek.tsx— Wochenraster (Desktop) / Tagesliste (Mobile) mit textuellem Status (nicht nur Farbe).components/portal/TodayCard.tsx— Dashboard-Karte „Heute" (Server Component,async).lib/guardian/db.ts(updateGuardianPortalLinkAccessLevel) /lib/guardian/actions.ts(updateGuardianPortalAccessLevelAction) — Admin-seitige Änderung der Zugriffsstufe.lib/portal/attendance.ts/absence-note.ts— Fehlzeiten-DTO, Entschuldigung einreichen/zurückziehen, Schnell-Krankmeldung.components/portal/StudentAttendanceView.tsx/AbsenceNoteForm.tsx/QuickSickReportButton.tsx— Fehlzeiten-Seite, Formular, Krankmeldung-Button (Dashboard + Kind-Seite).lib/klassenbuch/permissions.ts(canReviewAbsenceNote) /lib/actions/klassenbuch/absence-note.ts(reviewAbsenceNote) /components/klassenbuch/PendingAbsenceNotesPanel.tsx— Annehmen/Ablehnen durch die Klassenlehrkraft.lib/audit/actor.ts(portalActor) — Audit-Akteur für Schreibzugriffe, die von einem Portal-Nutzer ausgehen.proxy.ts— Schutzlogik für/portal/*(nur Seiten, keine API-Routen/Server Actions — siehe oben).