Stand: 2026-04-29
Bezug: ergänzt und ersetzt das Schema des Vorprojekts (sls-projekt/lsp-webapp/sql/schema.sql).
- Engine: InnoDB, Charset: utf8mb4, Collation: utf8mb4_unicode_ci
- Primärschlüssel:
id BIGINT UNSIGNED AUTO_INCREMENT - Zeitstempel:
created_at,updated_at(DATETIME, ON UPDATE) - Soft-Deletes:
deleted_at(DATETIME NULL) wo sinnvoll - Verschlüsselte Felder:
VARBINARY(512)mit Suffix_encrypted - Hashes:
VARCHAR(255)(bcrypt/argon2id) - Aufzählungen: nicht als ENUM, sondern als
VARCHAR+ Lookup-Tabelle, wo später erweiterbar (sonst Migrations-Overhead) - Audit: zentrale
audit_logs-Tabelle - Migrations: Laravel-Migrations, eine pro logischer Änderung
[Identität & Auth] users, user_groups, user_group_memberships,
permissions, group_permissions,
user_permission_overrides, user_scope_assignments,
two_factor_secrets
[Krypto] encryption_keys, key_wraps, recovery_keys
[Schule & Stammdaten] school_years, learning_groups, students,
student_enrollments
[Test-Konfiguration] questionnaires, questionnaire_questions,
questionnaire_practice_questions,
norm_tables, norm_table_rows,
feedback_sets, feedback_set_ranges,
notice_texts, assessment_types,
support_thresholds
[Erhebungen] test_runs, test_run_groups, test_run_security,
test_attempts, attempt_answers,
attempt_lq_history
[Drucksachen] print_templates, print_template_versions,
print_jobs, generated_documents
[Mail] mail_settings, mail_messages, mail_attachments
[Backup] backup_targets, backup_runs
[Import] import_sources, import_jobs, import_diff_entries,
student_imports
[Audit & Logs] audit_logs, export_logs, login_logs
Persönliche Benutzerkonten (keine Schüler).
id, username (unique), email (nullable), display_name,
password_hash, is_active,
two_factor_enabled (bool), two_factor_secret_id (nullable FK),
last_login_at, password_changed_at,
created_at, updated_at, deleted_at
Frei anlegbare Benutzerklassen.
id, name (unique), description, is_system (bool, default false),
sort_order, created_at, updated_at
Defaults beim Setup: Admin, Schulleitung, Sekretariat, Lehrkraft (is_system = 1).
Ein User kann in mehreren Gruppen sein.
id, user_id, user_group_id,
created_at, updated_at,
UNIQUE(user_id, user_group_id)
Statischer Katalog (im Code definiert, beim Migrate eingespielt).
id, key (unique, z.B. "students.view"), description, area (z.B. "students"),
is_scopeable (bool), created_at
is_scopeable = 1: Permission kann auf Lerngruppen gescoped werdenis_scopeable = 0: nur global vergebbar (z.B.system.backup)
Permissions, die einer Benutzerklasse generell gegeben werden.
id, user_group_id, permission_id,
created_at, UNIQUE(user_group_id, permission_id)
Per-User-Overlay über Gruppen-Permissions.
id, user_id, permission_id,
mode ENUM('grant','revoke'),
created_at, UNIQUE(user_id, permission_id)
Welche Lerngruppen ein User sehen darf (Scope).
- Wenn keine Einträge vorhanden: User ist ungescoped (sieht alles).
- Wenn Einträge vorhanden: User ist auf diese Gruppen eingeschränkt.
id, user_id, learning_group_id,
created_at, UNIQUE(user_id, learning_group_id)
Default-Verhalten Lehrkraft: Beim Anlegen ohne Scope-Einträge → schulweit. Sobald Lerngruppen zugewiesen sind → nur diese.
id, user_id (unique), secret_encrypted, recovery_codes_encrypted,
confirmed_at, created_at
Eine aktive DEK (Data Encryption Key) zu jedem Zeitpunkt; ältere Versionen für Re-Encryption-Vorgänge.
id, key_version (unique), is_active (bool, nur eine pro Schule),
created_at, retired_at
- Die DEK selbst wird niemals im Klartext gespeichert.
- Sie existiert nur in Memory einer Session, nachdem sie entwrapped wurde.
DEK gewrapped pro berechtigtem Schlüsselträger (User-Passwort oder Recovery-Key).
id, encryption_key_id (FK -> encryption_keys),
wrap_type ENUM('user_password','recovery_key'),
user_id (nullable, nur bei wrap_type=user_password),
wrapped_dek BLOB, -- mit KEK aus Passwort verschlüsselte DEK
kdf_salt BLOB, -- Salt für PBKDF2/Argon2id
kdf_params JSON, -- iterations / memory / parallelism
verification_hash VARCHAR(255), -- für schnelle Passwort-Verifikation
created_at, updated_at,
UNIQUE(encryption_key_id, user_id, wrap_type)
Metadaten zum Recovery-Key (der Schlüssel selbst wird nicht gespeichert!).
id, encryption_key_id (FK), label, fingerprint VARCHAR(64),
created_at, used_at, revoked_at
- Beim Setup wird der Recovery-Key generiert, einmalig im UI angezeigt und ein Wrap dafür angelegt.
fingerprint= SHA-256 des Recovery-Keys (zur Identifikation, nicht zur Wiederherstellung).
id, label (z.B. "2026/27", unique), start_date, end_date,
is_active, is_archived,
created_at, updated_at
Klassen und Kurse (ehemals groups).
id, school_year_id (FK, ON DELETE CASCADE),
name (z.B. "5a"), description,
group_type ENUM('klasse','kurs'),
grade_level VARCHAR(10), -- z.B. "5", "9", "EF"
sort_order, is_active,
created_at, updated_at,
UNIQUE(school_year_id, name, group_type)
Persistente Schüleridentität über die gesamte Schullaufbahn.
id, -- interne stabile Identität
external_student_id VARCHAR(50), -- SchiLD-/SVWS-ID, Match-Anker
external_id_source VARCHAR(20), -- 'schild' | 'svws' | 'manual'
student_code VARCHAR(20) UNIQUE, -- LSP-eigenes Kürzel (anzeigetauglich)
first_name_encrypted VARBINARY(512),
last_name_encrypted VARBINARY(512),
gender ENUM('m','w','d','unbekannt') DEFAULT 'unbekannt',
date_of_birth_encrypted VARBINARY(512) NULL, -- optional, falls für Norm relevant
status ENUM('aktiv','archiviert') DEFAULT 'aktiv',
archived_at DATETIME NULL,
archived_reason VARCHAR(255) NULL,
deleted_at DATETIME NULL,
created_at, updated_at,
UNIQUE(external_student_id, external_id_source)
- NICHT an
school_year_idgebunden. - Schullaufbahn-Daten kommen aus
student_enrollments.
Schüler in welchem Schuljahr in welchen Lerngruppen.
id, student_id (FK),
school_year_id (FK),
grade_level VARCHAR(10), -- für schnelle Filter (z.B. Klasse 7)
is_repeater (bool, default false), -- falls Sitzenbleiber
enrolled_at, ended_at,
created_at, updated_at,
UNIQUE(student_id, school_year_id)
N:M Schüler ↔ Lerngruppen (innerhalb des Enrollments).
id, student_id, learning_group_id,
school_year_id (FK, redundant für schnelle Joins),
created_at, updated_at,
UNIQUE(student_id, learning_group_id)
Testheft / Fragebogen mit Parallelform.
id, name, description,
parallel_form VARCHAR(10), -- "A1","A2","B1","B2", frei
grade_level_target VARCHAR(20), -- "5-6", "7-9", informativ
default_time_limit_seconds INT UNSIGNED DEFAULT 180,
practice_time_seconds INT UNSIGNED DEFAULT 30,
status ENUM('entwurf','aktiv','archiviert'),
created_by_user_id (FK -> users),
created_at, updated_at
id, questionnaire_id (FK, ON DELETE CASCADE),
sort_order INT UNSIGNED,
question_text TEXT,
correct_answer ENUM('richtig','falsch'),
is_active,
created_at, updated_at,
UNIQUE(questionnaire_id, sort_order)
Übungsfragen (vor dem eigentlichen Test).
id, questionnaire_id (FK, ON DELETE CASCADE),
sort_order, question_text TEXT,
correct_answer ENUM('richtig','falsch'),
created_at, updated_at,
UNIQUE(questionnaire_id, sort_order)
3-dimensional: Schulstufe × Geschlecht × Parallelform → (Rohwert → LQ)
id, name, version_label,
grade_level VARCHAR(10), -- "5","6","7","8","9","10"
parallel_form VARCHAR(10), -- muss zur Fragebogen-Form passen
source_type ENUM('csv','xlsx','manuell'),
status ENUM('entwurf','aktiv','archiviert'),
is_active (bool),
created_by_user_id (FK), created_at, updated_at
id, norm_table_id (FK, ON DELETE CASCADE),
raw_score INT,
quotient_female INT,
quotient_male INT,
quotient_diverse INT NULL, -- optional, sonst Mittel oder NULL
UNIQUE(norm_table_id, raw_score)
Sammlung von Rückmeldetexten/-bereichen.
id, name, status ENUM('entwurf','aktiv','archiviert'),
is_default (bool),
created_by_user_id, created_at, updated_at
id, feedback_set_id (FK, ON DELETE CASCADE),
sort_order, name,
match_type ENUM('punkte','lq'), -- worauf bezieht sich min/max
min_value INT, max_value INT,
template_html LONGTEXT, -- HTML-Snippet für die Rückmeldung
is_active,
created_at, updated_at
Hinweistexte für die Schüler-Sicht vor dem Test.
id, name, content TEXT,
is_default, status,
created_by_user_id, created_at, updated_at
Frei konfigurierbare Erhebungs-Typen (Eingangstest etc.).
id, key (unique, z.B. "eingangstest"), label,
sort_order, is_active,
created_at, updated_at
Vom Admin konfigurierbare Förderbedarf-Schwellen.
id, name, description,
metric ENUM('lq_absolute','lq_delta','lq_below_class_median'),
operator ENUM('lt','le','gt','ge','eq'),
value INT,
window_count INT NULL, -- für lq_delta: über letzte n Erhebungen
severity ENUM('hinweis','auffaellig','foerderbedarf'),
is_active,
created_at, updated_at
Konkreter Testdurchlauf.
id, school_year_id (FK),
assessment_type_id (FK -> assessment_types),
name, short_code VARCHAR(20) UNIQUE,
status ENUM('in_vorbereitung','aktiv','pausiert','abgeschlossen','archiviert'),
questionnaire_id (FK), norm_table_id (FK),
feedback_set_id (FK), notice_text_id (FK),
time_limit_seconds INT UNSIGNED DEFAULT 180,
practice_time_seconds INT UNSIGNED DEFAULT 30,
show_score_to_student (bool),
allow_teacher_reset (bool),
scheduled_for DATE NULL, -- Plan-Termin (informativ)
created_by_user_id (FK), owner_user_id (FK),
created_at, updated_at
Welche Lerngruppen am Durchlauf teilnehmen.
id, test_run_id (FK), learning_group_id (FK),
created_at,
UNIQUE(test_run_id, learning_group_id)
Pro Durchlauf: Lehrkraftcode, Klarname-Freigabecode.
id, test_run_id (FK, UNIQUE),
teacher_access_code CHAR(12) NULL,
teacher_access_code_is_active (bool),
clearname_release_code_hash VARCHAR(255),
clearname_code_generated_at DATETIME,
created_by_user_id (FK), created_at, updated_at
Ein Versuch eines Schülers in einem Durchlauf.
id, student_id (FK), test_run_id (FK),
questionnaire_id (FK), -- Snapshot, damit auch nach Run-Update stabil
parallel_form VARCHAR(10), -- Snapshot
norm_table_id (FK NULL), -- Snapshot der zum Berechnungszeitpunkt verwendeten Norm
status ENUM('gestartet','abgegeben','zeit_abgelaufen','abgebrochen','zurueckgesetzt'),
started_at, submitted_at,
time_limit_seconds INT UNSIGNED, -- Snapshot
score_raw INT UNSIGNED DEFAULT 0, -- Rohwert (immutable nach Abgabe)
lq_at_submission INT NULL, -- LQ zum Zeitpunkt der Abgabe (Snapshot)
lq_current INT NULL, -- aktuell berechneter LQ (kann nach Norm-Update neu berechnet werden)
lq_calculated_at DATETIME NULL,
ended_by ENUM('schueler','system','admin','lehrkraft') NULL,
reset_by_user_id (FK NULL), reset_reason VARCHAR(255),
login_code_used CHAR(10), -- welcher Einmalcode
created_at, updated_at
id, test_attempt_id (FK, ON DELETE CASCADE),
question_id (FK),
given_answer ENUM('richtig','falsch'),
is_correct (bool),
answered_at DATETIME,
UNIQUE(test_attempt_id, question_id)
Historie der LQ-Berechnungen, falls Normtabelle nachträglich geändert wurde.
id, test_attempt_id (FK, ON DELETE CASCADE),
norm_table_id (FK),
lq_value INT,
calculated_at DATETIME,
calculated_by_user_id (FK NULL),
reason VARCHAR(255) -- "initial", "norm_table_updated", "manual_recalc"
Einmal-Zugangscodes pro Schüler pro Durchlauf.
id, student_id (FK), test_run_id (FK),
login_code CHAR(10) UNIQUE,
status ENUM('aktiv','in_bearbeitung','verbraucht','gesperrt'),
issued_at, consumed_at, reset_at,
reset_by_user_id (FK NULL),
created_at, updated_at,
UNIQUE(student_id, test_run_id)
Begründung: Vom Vorprojekt entkoppelt – statt eines globalen
login_codeam Schüler gibt es einen Code pro Testdurchlauf. Erlaubt mehrere Erhebungen pro Schuljahr und sauberen Reset.
id, key VARCHAR(100) UNIQUE, -- z.B. "rueckmeldung","qr_liste","verlaufsdiagramm"
name, description, type VARCHAR(50),
is_system (bool), -- system-templates kann man duplizieren, aber nicht löschen
current_version_id (FK -> print_template_versions, nullable),
created_at, updated_at
Versionierte Vorlagen.
id, print_template_id (FK, ON DELETE CASCADE),
version_number INT,
html_content LONGTEXT,
css_content LONGTEXT,
variables_schema JSON, -- welche Variablen verfügbar
notes TEXT,
created_by_user_id (FK), created_at,
UNIQUE(print_template_id, version_number)
Asynchrone PDF-Generierungs-Aufträge.
id, print_template_version_id (FK),
context_type VARCHAR(50), -- 'attempt','test_run','student','custom'
context_id BIGINT UNSIGNED,
parameters JSON,
status ENUM('pending','running','done','failed'),
error_message TEXT NULL,
output_document_id (FK NULL),
requested_by_user_id (FK),
requested_at, started_at, finished_at
Fertige PDFs.
id, file_name, file_path,
mime_type VARCHAR(50), size_bytes BIGINT,
includes_clearnames (bool),
sha256 CHAR(64),
expires_at DATETIME NULL, -- Default: now+30d
created_by_user_id (FK),
created_at
id (=1, Singleton),
smtp_host, smtp_port, smtp_username, smtp_password_encrypted,
smtp_encryption ENUM('tls','starttls','none'),
from_address, from_name,
reply_to,
is_active,
updated_by_user_id (FK), updated_at
id, to_addresses TEXT, cc TEXT, bcc TEXT,
subject, body_html LONGTEXT, body_text LONGTEXT,
status ENUM('queued','sent','failed','bounced'),
error_message TEXT,
related_entity_type VARCHAR(50), related_entity_id BIGINT UNSIGNED,
includes_clearnames (bool),
sent_by_user_id (FK), sent_at, created_at
id, mail_message_id (FK, ON DELETE CASCADE),
generated_document_id (FK NULL),
file_name, mime_type, size_bytes,
created_at
id, name, type ENUM('local','sftp','s3'),
config_encrypted JSON, -- Verbindungsdaten verschlüsselt
encryption_password_encrypted VARBINARY(512), -- Backup-Verschlüsselungspasswort
retention_daily INT DEFAULT 7,
retention_weekly INT DEFAULT 4,
retention_monthly INT DEFAULT 12,
is_active,
created_at, updated_at
id, backup_target_id (FK),
trigger ENUM('manual','scheduled'),
status ENUM('running','success','failed'),
started_at, finished_at,
file_name, size_bytes BIGINT,
sha256 CHAR(64),
includes_db (bool), includes_files (bool), includes_config (bool),
error_message TEXT,
triggered_by_user_id (FK NULL)
Konfiguration der Importquellen (z.B. SVWS-Endpunkt).
id, key VARCHAR(50) UNIQUE, -- 'schild_csv','svws_api','manual'
name, type VARCHAR(50),
config_encrypted JSON NULL, -- z.B. SVWS API URL/Token
is_active,
created_at, updated_at
Mehrstufiger Importprozess.
id, import_source_id (FK), school_year_id (FK),
group_type ENUM('klasse','kurs'),
filename VARCHAR(255) NULL,
status ENUM('uploaded','validated','diff_ready','committed','aborted','failed'),
mapping JSON, -- Spalten-Mapping
stats JSON, -- {total, valid, errors, archive_candidates, ...}
started_by_user_id (FK),
started_at, validated_at, committed_at,
error_message TEXT
Einzelne Diff-Zeilen je Importjob.
id, import_job_id (FK, ON DELETE CASCADE),
row_number INT,
external_student_id VARCHAR(50),
action ENUM('create','update','archive','skip','error'),
matched_student_id (FK NULL),
payload JSON, -- die Roh-Eingabe (verschlüsselt wo Klarname)
errors JSON,
admin_decision ENUM('confirm','exclude') DEFAULT 'confirm',
admin_decision_reason TEXT NULL,
created_at
Abgeschlossener Importlauf, für Auditing.
id, import_job_id (FK NULL),
school_year_id (FK NULL),
filename, source_key,
rows_total, rows_imported, rows_updated, rows_archived, rows_skipped,
imported_by_user_id (FK), imported_at
id, actor_type ENUM('user','system','student','external'),
actor_user_id (FK NULL),
action VARCHAR(100),
entity_type VARCHAR(100), entity_id BIGINT UNSIGNED NULL,
context JSON, -- z.B. {test_run_id, ...}
includes_clearnames (bool), -- für DSGVO-relevante Aktionen
ip_address VARCHAR(45), user_agent VARCHAR(255),
created_at,
INDEX(actor_user_id, created_at),
INDEX(entity_type, entity_id),
INDEX(action, created_at)
id, export_type VARCHAR(50),
context_type VARCHAR(50), context_id BIGINT UNSIGNED,
generated_document_id (FK NULL),
includes_clearnames (bool),
triggered_by_user_id (FK), created_at
id, user_id (FK NULL), -- NULL bei unbekanntem Benutzernamen
username_attempted VARCHAR(100),
ip_address VARCHAR(45), user_agent VARCHAR(255),
result ENUM('success','wrong_password','unknown_user','locked','two_factor_failed','two_factor_required'),
created_at
| Bereich | Alt | Neu | Begründung |
|---|---|---|---|
| Schüler-Identität | An school_year_id gebunden |
Stabil über Laufbahn (students ohne school_year_id), Zuordnung über student_enrollments |
Längsschnitt 5–10/8 Jahre |
| Login-Code | Globaler Einmalcode am Schüler | Pro Schüler+Testdurchlauf separater Code (student_login_codes) |
Mehrere Erhebungen pro Jahr möglich |
| Normtabellen | Nur raw_score → m/w |
3D: Schulstufe × Geschlecht × Parallelform → LQ, dazu parallel_form an questionnaires |
SLS-konform |
| LQ am Versuch | Ein einziger Wert | score_raw (immutable), lq_at_submission (Snapshot), lq_current (re-berechenbar) + attempt_lq_history |
Norm-Updates rückwirkend möglich, aber nachvollziehbar |
| Klarnamen-Krypto | Passwort direkt = Schlüssel; Wechsel = alle Daten neu verschlüsseln | Envelope Encryption (DEK + n Wraps + Recovery-Key) | Multi-User, schneller Wechsel, Recovery |
| Permissions | Hartkodierte Rollen admin/lehrkraft |
Granulare permissions + frei anlegbare user_groups + Scopes + Overrides |
Skalierbar, schulindividuell |
| Erhebungstyp | Implizit am Testdurchlauf | Eigene Tabelle assessment_types |
Vor-/Nachtest-Vergleiche, Filter |
| Förderbedarf | Nicht modelliert | support_thresholds konfigurierbar |
Kernfeature der Längsschnittauswertung |
| Druck-Templates | LaTeX-Strings im feedback_set_ranges |
Eigene print_templates mit Versionen + HTML/CSS + WYSIWYG |
Bearbeitbar im UI, alle Drucksachen, reproduzierbar |
| PDF-Generierung | Keine (nur LaTeX-Download) | Async via Queue + Gotenberg, print_jobs/generated_documents |
Serverseitig, alles als PDF |
| Backup | Nicht modelliert | backup_targets, backup_runs |
Pflicht für produktiven Betrieb |
| Import | Direkter Commit | import_jobs mit Phasen (Validate → Diff → Commit), import_diff_entries mit Archivkandidaten |
Geführter Assistent, Dry-Run, Diff |
| Nicht vorhanden | mail_settings, mail_messages, mail_attachments |
SMTP-Versand mit Protokoll | |
| Audit | Eine Tabelle, eher grob | Erweitert um includes_clearnames, IP, User-Agent + dedizierte login_logs |
DSGVO-relevant |
students(external_student_id, external_id_source)— UNIQUE, Match beim Importstudents(student_code)— UNIQUE, Anzeigestudent_login_codes(login_code)— UNIQUE, Schüler-Loginstudent_enrollments(student_id, school_year_id)— UNIQUEstudent_group_memberships(student_id, learning_group_id)— UNIQUEtest_attempts(student_id, test_run_id)— INDEX (Längsschnitt-Query)attempt_answers(test_attempt_id, question_id)— UNIQUEaudit_logs(actor_user_id, created_at)— INDEXaudit_logs(entity_type, entity_id)— INDEX
SELECT
ta.id, ta.score_raw, ta.lq_current, ta.lq_at_submission,
ta.submitted_at,
tr.name AS test_run_name,
at.label AS assessment_type,
sy.label AS school_year,
lg.name AS learning_group,
nt.name AS norm_table_name
FROM test_attempts ta
JOIN test_runs tr ON tr.id = ta.test_run_id
JOIN assessment_types at ON at.id = tr.assessment_type_id
JOIN school_years sy ON sy.id = tr.school_year_id
LEFT JOIN norm_tables nt ON nt.id = ta.norm_table_id
LEFT JOIN student_group_memberships sgm
ON sgm.student_id = ta.student_id
AND sgm.school_year_id = tr.school_year_id
LEFT JOIN learning_groups lg ON lg.id = sgm.learning_group_id
WHERE ta.student_id = ?
AND ta.status IN ('abgegeben','zeit_abgelaufen')
ORDER BY ta.submitted_at;Iteriert über aktive support_thresholds, wendet jedes Kriterium auf die letzten Versuche jedes aktiven Schülers an, vereinigt die Treffer mit der höchsten severity.
für jede Zeile aus dem Import:
suche student WHERE external_student_id=row.id AND external_id_source=source
wenn gefunden:
wenn Daten unverändert → action=skip
sonst → action=update mit Δ
sonst:
action=create
aktive Studenten ohne Match in Importzeilen UND deren letztes Enrollment im aktuellen Schuljahr:
action=archive (Begründung: "nicht in aktuellem Import")
1. User gibt Klarnamen-Passwort ein
2. Lade key_wraps WHERE user_id=? AND wrap_type='user_password' AND active EncKey
3. KEK = Argon2id(password, kdf_salt, kdf_params)
4. DEK = AES-Unwrap(wrapped_dek, KEK)
5. Verifikation: hash(DEK) == verification_hash
6. DEK in Session speichern (nur memory-Cache, nicht persistent)
7. Audit: "clearname.unlock"
Werden im nächsten Dokument (03-permissions.md) konkretisiert:
- Vollständige Liste der Permission-Keys mit Beschreibung
- Welche sind
is_scopeable? - Welche Permissions bekommen die Default-Klassen (Admin / Schulleitung / Sekretariat / Lehrkraft) initial?
- Welche Permissions sind 2FA-Pflicht (z.B.
clearname.unlock,students.delete)?