Files
geograsim/App/php/api/modules.php
T
Adminator ff889865e1 Lizenz-Gate: Schueler ohne Lizenz gesperrt + Lehrer-Cockpit-Warnung
Teil 1 — Schueler-Lizenz-Gate (gilt fuer alle Klassen-Schueler ohne gueltige
Lizenz = zugewiesen, nicht storniert, nicht abgelaufen):
- Server-Gate zentral im Front-Controller (index.php + php/lib/license_gate.php):
  eingeloggte Klassen-Schueler ohne Lizenz sehen bei JEDER Sim-Route statt der
  Simulation eine Hinweis-Seite "Deine Lehrperson muss dir noch eine gueltige
  Lizenz zuweisen". Schnell-Ausstieg fuer Nicht-Schueler/Nicht-Sim (kein Cookie
  -> keine DB-Abfrage). Autodidakt ohne Klasse: nicht lizenzpflichtig.
- modules.php?student=1 liefert 'licensed'; Schueler-Cockpit (schueler.html)
  zeigt oben die Meldung und sperrt die Start-Buttons.

Teil 2 — Lehrer-Cockpit:
- Rote Warnleiste ganz oben, wenn Schueler:innen ohne gueltige Lizenz in der
  Klasse sind (+ Link "Jetzt zuweisen"). licenses?class_id=X liefert dazu die
  Liste 'unlicensed'.
- Lizenzen-Lasche: neue Sektion "Ohne gueltige Lizenz" mit Einzel- und
  Sammel-Zuweisung (auto_assign) — sonst fuehrt die Warnung ins Leere.

Kunden-Vereinfachung (Feedback): keine Auto-Erkennung/Dedup mehr (Ansprech-
partner koennen wechseln). Kunde = Freitext "Name/Verlag/Schule" + Mailadresse,
je Bestellung gespeichert. Editierbares Kundennummer-Feld + Autocomplete entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 16:15:16 +02:00

342 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* API: Modulfreigabe (Klasse + individuell pro Schüler)
* GET /api/modules?class_id=X → Modulstatus für Klasse
* GET /api/modules?class_id=X&matrix=1 → Matrix: alle Schüler × alle Module
* POST /api/modules {action} → Modul freigeben/sperren
*
* Bootstrap: idempotent, damit auch direkter /php/api/modules.php-Aufruf funktioniert
* (live-client.js pollt von der Sim aus diesen Direkt-Pfad).
*/
require_once __DIR__ . '/../config/app.php';
require_once __DIR__ . '/../lib/Database.php';
require_once __DIR__ . '/../lib/Session.php';
require_once __DIR__ . '/../lib/Response.php';
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
// Default-Modus für eine Klasse OHNE expliziten Eintrag: eine neue Klasse startet
// mit NUR Weltküche frei (Einstiegs-Sim), alles andere gesperrt. Die Lehrperson
// gibt weitere Module über die Freigabe-Matrix frei.
function _defaultMode($moduleId) { return $moduleId === 'weltkueche' ? 'free' : 'locked'; }
// Modul-Katalog dynamisch aus module_info (Source of Truth)
// Nur aktive + beta + geplante Module anzeigen (keine archivierten)
$ALL_MODULES = $db->fetchAll(
"SELECT module_id AS id, title AS name, short_desc AS `desc`, icon,
card_image, status, play_url, sort_order, available_from
FROM module_info
WHERE status IN ('aktiv','beta','geplant')
ORDER BY sort_order, title"
);
if ($method === 'GET') {
// Schüler*innen-Ansicht: welche Module sind für mich freigeschaltet?
if (isset($_GET['student']) && $_GET['student'] === '1') {
$sessionId = Session::requireStudent();
$session = $db->fetchOne('SELECT class_id FROM student_sessions WHERE id = ?', [$sessionId]);
if (!$session || !$session['class_id']) {
// Kein Klasse = alle Module frei (Autodidakt)
$result = [];
foreach ($ALL_MODULES as $mod) {
$result[] = [
'id' => $mod['id'],
'name' => $mod['name'],
'desc' => $mod['desc'],
'icon' => $mod['icon'],
'card_image' => $mod['card_image'],
'status' => $mod['status'],
'play_url' => $mod['play_url'],
'available_from' => $mod['available_from'],
'mode' => $mod['status'] === 'geplant' ? 'coming_soon' : 'free',
'licensed' => true, // Autodidakt ohne Klasse: nicht lizenzpflichtig
];
}
Response::ok($result);
}
$classId = $session['class_id'];
$classMap = [];
$settings = $db->fetchAll(
'SELECT module_id, mode, current_level, quiz_enabled, audio_info, due_date, started_at, paused
FROM class_modules WHERE class_id = ?', [$classId]);
foreach ($settings as $s) $classMap[$s['module_id']] = $s;
// Pro Sim den letzten Abschluss-Zeitpunkt des Schülers laden (assessments).
// Wird gegen class_modules.started_at verglichen, um teacher_started-
// Aufträge als "erledigt" zu markieren, sobald der Schüler nach dem
// Auftrag-Start einmal abgeschlossen hat.
$lastDone = [];
$rows = $db->fetchAll(
'SELECT sim_id, MAX(submitted_at) AS last_at FROM assessments WHERE session_id = ? GROUP BY sim_id',
[$sessionId]
);
foreach ($rows as $r) $lastDone[$r['sim_id']] = $r['last_at'];
// Individuelle Overrides für diesen Schüler
$studentRow = $db->fetchOne(
'SELECT student_id AS id FROM student_sessions WHERE id = ?',
[$sessionId]
);
$overrides = [];
if ($studentRow) {
$rows = $db->fetchAll('SELECT module_id, mode FROM student_modules WHERE student_id = ?', [$studentRow['id']]);
foreach ($rows as $r) $overrides[$r['module_id']] = $r['mode'];
}
// Lizenz-Status des Schülers (fürs Cockpit-Gate). Gültig = zugewiesen,
// nicht storniert, nicht abgelaufen (valid_until >= heute; NULL = Alt-Code).
$licensed = false;
if ($studentRow && !empty($studentRow['id'])) {
$lic = $db->fetchOne(
"SELECT 1 AS ok FROM licenses WHERE student_id = ? AND canceled_at IS NULL
AND (valid_until IS NULL OR valid_until >= CURDATE()) LIMIT 1",
[$studentRow['id']]);
$licensed = (bool)$lic;
}
// Einzelner Modul-Filter
$filterId = $_GET['module_id'] ?? null;
$result = [];
foreach ($ALL_MODULES as $mod) {
if ($filterId && $mod['id'] !== $filterId) continue;
$cm = $classMap[$mod['id']] ?? null;
$mode = $overrides[$mod['id']] ?? ($cm['mode'] ?? _defaultMode($mod['id']));
// 'geplant' überschreibt jeden mode-Eintrag — Modul ist nicht spielbar.
if ($mod['status'] === 'geplant') $mode = 'coming_soon';
// Lehrer-Auftrag erledigt? (assessment-submit nach class_modules.started_at)
$assignmentCompleted = false;
if ($mode === 'teacher_started' && $cm && !empty($cm['started_at'])) {
$done = $lastDone[$mod['id']] ?? null;
if ($done && $done > $cm['started_at']) $assignmentCompleted = true;
}
$result[] = [
'id' => $mod['id'],
'name' => $mod['name'],
'desc' => $mod['desc'],
'icon' => $mod['icon'],
'card_image' => $mod['card_image'],
'status' => $mod['status'],
'play_url' => $mod['play_url'],
'available_from' => $mod['available_from'],
'mode' => $mode,
'forcedLevel' => ($mode === 'teacher_started' && $cm) ? (int)($cm['current_level'] ?? 1) : null,
'quizEnabled' => (bool)($cm['quiz_enabled'] ?? 0),
'audioInfo' => (bool)($cm['audio_info'] ?? 1),
'dueDate' => $cm['due_date'] ?? null,
'paused' => (bool)($cm['paused'] ?? 0),
'assignmentCompleted' => $assignmentCompleted,
'licensed' => $licensed,
];
}
// Wenn einzelnes Modul, liefere Objekt statt Array
if ($filterId) {
Response::ok($result[0] ?? ['id' => $filterId, 'mode' => 'locked', 'error' => 'not_found']);
}
Response::ok($result);
}
$teacherId = Session::requireTeacher();
$classId = (int)($_GET['class_id'] ?? 0);
if (!$classId) Response::error('class_id erforderlich');
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
// Klassen-Defaults laden (inkl. Level + Pause-Status für die Lehrer-UI)
$settings = $db->fetchAll(
'SELECT module_id, mode, current_level, paused, audio_info, due_date, started_at FROM class_modules WHERE class_id = ?',
[$classId]
);
$classMap = [];
$classMeta = [];
foreach ($settings as $s) {
$classMap[$s['module_id']] = $s['mode'];
$classMeta[$s['module_id']] = $s;
}
// Matrix-Modus: alle Schüler × alle Module
if (isset($_GET['matrix']) && $_GET['matrix'] === '1') {
$students = $db->fetchAll(
'SELECT id, username, display_name, emoji_avatar FROM students WHERE class_id = ? ORDER BY username',
[$classId]
);
// Individuelle Overrides laden
$studentIds = array_column($students, 'id');
$overrides = [];
if (!empty($studentIds)) {
$placeholders = implode(',', array_fill(0, count($studentIds), '?'));
$rows = $db->fetchAll(
"SELECT student_id, module_id, mode FROM student_modules WHERE student_id IN ($placeholders)",
$studentIds
);
foreach ($rows as $r) {
$overrides[$r['student_id']][$r['module_id']] = $r['mode'];
}
}
// Matrix aufbauen
$matrix = [];
foreach ($students as $s) {
$row = [
'id' => (int)$s['id'],
'username' => $s['username'],
'displayName' => $s['display_name'] ?: $s['username'],
'emoji' => $s['emoji_avatar'] ?: '🧑‍🎓',
'modules' => [],
];
foreach ($ALL_MODULES as $mod) {
$row['modules'][$mod['id']] = $overrides[$s['id']][$mod['id']] ?? $classMap[$mod['id']] ?? _defaultMode($mod['id']);
}
$matrix[] = $row;
}
// Modul-Infos mit Klassen-Defaults
$modulesInfo = [];
foreach ($ALL_MODULES as $mod) {
$meta = $classMeta[$mod['id']] ?? null;
$modulesInfo[] = [
'id' => $mod['id'],
'name' => $mod['name'],
'desc' => $mod['desc'],
'icon' => $mod['icon'],
'card_image' => $mod['card_image'],
'status' => $mod['status'],
'available_from' => $mod['available_from'],
'classMode' => $classMap[$mod['id']] ?? _defaultMode($mod['id']),
'currentLevel' => $meta ? (int)($meta['current_level'] ?? 1) : 1,
'paused' => $meta ? (bool)($meta['paused'] ?? 0) : false,
'audioInfo' => $meta ? (bool)($meta['audio_info'] ?? 1) : true,
'dueDate' => $meta['due_date'] ?? null,
'startedAt' => $meta['started_at'] ?? null,
];
}
Response::ok(['modules' => $modulesInfo, 'students' => $matrix]);
}
// Standard: nur Klassen-Defaults
$result = [];
foreach ($ALL_MODULES as $mod) {
$result[] = [
'id' => $mod['id'],
'name' => $mod['name'],
'desc' => $mod['desc'],
'icon' => $mod['icon'],
'status' => $mod['status'],
'mode' => $classMap[$mod['id']] ?? _defaultMode($mod['id']),
];
}
Response::ok($result);
}
if ($method === 'POST') {
$teacherId = Session::requireTeacher();
$body = json_decode(file_get_contents('php://input'), true);
$action = $body['action'] ?? '';
$classId = (int)($body['classId'] ?? 0);
$moduleId = $body['moduleId'] ?? '';
if (!$classId || !$moduleId) Response::error('classId und moduleId erforderlich');
$class = $db->fetchOne('SELECT id FROM classes WHERE id = ? AND teacher_id = ?', [$classId, $teacherId]);
if (!$class) Response::error('Klasse nicht gefunden', 404);
// Schutz: 'geplant'-Module dürfen NICHT freigeschaltet werden — sie sind
// im Lehrer-UI auch nur als 'In Vorbereitung' sichtbar.
$modStatus = $db->fetchOne('SELECT status FROM module_info WHERE module_id = ?', [$moduleId]);
if (!$modStatus || $modStatus['status'] === 'geplant' || $modStatus['status'] === 'archiv') {
Response::error('Modul ist noch in Vorbereitung und kann nicht freigeschaltet werden.');
}
// Klassen-Default setzen
if ($action === 'set_mode') {
$mode = $body['mode'] ?? 'locked';
if (!in_array($mode, ['locked', 'free', 'teacher_started'])) Response::error('Ungültiger Modus');
$level = isset($body['currentLevel']) ? max(1, min(3, (int)$body['currentLevel'])) : 1;
$dueDate = !empty($body['dueDate']) ? $body['dueDate'] : null;
$paused = !empty($body['paused']) ? 1 : 0;
$db->execute(
'INSERT INTO class_modules (class_id, module_id, enabled, mode, current_level, due_date, paused)
VALUES (?, ?, 1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE mode = VALUES(mode), enabled = 1,
current_level = VALUES(current_level), due_date = VALUES(due_date), paused = VALUES(paused)',
[$classId, $moduleId, $mode, $level, $dueDate, $paused]
);
// Bei "teacher_started" started_at frisch setzen — markiert Auftragsstart
// und macht alte assessments unter dieser started_at zu "vorher".
if ($mode === 'teacher_started') {
$db->execute(
'UPDATE class_modules SET started_at = NOW() WHERE class_id = ? AND module_id = ?',
[$classId, $moduleId]
);
} else {
$db->execute(
'UPDATE class_modules SET started_at = NULL, paused = 0 WHERE class_id = ? AND module_id = ?',
[$classId, $moduleId]
);
}
Response::ok();
}
// Vorlese-Modus (Audio-Info) für die Klasse erlauben/sperren
if ($action === 'set_audio_info') {
$on = !empty($body['enabled']) ? 1 : 0;
$db->execute(
'INSERT INTO class_modules (class_id, module_id, enabled, audio_info)
VALUES (?, ?, 1, ?)
ON DUPLICATE KEY UPDATE audio_info = VALUES(audio_info)',
[$classId, $moduleId, $on]
);
Response::ok();
}
// Auftrag pausieren / fortsetzen
if ($action === 'pause' || $action === 'resume') {
$paused = $action === 'pause' ? 1 : 0;
$db->execute(
'UPDATE class_modules SET paused = ? WHERE class_id = ? AND module_id = ?',
[$paused, $classId, $moduleId]
);
Response::ok();
}
// Individuell pro Schüler setzen
if ($action === 'set_student_mode') {
$studentId = (int)($body['studentId'] ?? 0);
$mode = $body['mode'] ?? 'locked';
if (!$studentId) Response::error('studentId erforderlich');
if (!in_array($mode, ['locked', 'free', 'teacher_started'])) Response::error('Ungültiger Modus');
// Prüfen dass Schüler zur Klasse gehört
$student = $db->fetchOne('SELECT id FROM students WHERE id = ? AND class_id = ?', [$studentId, $classId]);
if (!$student) Response::error('Schüler nicht in dieser Klasse');
$db->execute(
'INSERT INTO student_modules (student_id, module_id, mode)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE mode = VALUES(mode)',
[$studentId, $moduleId, $mode]
);
Response::ok();
}
// Klassen-Default für ALLE Schüler übernehmen (reset overrides)
if ($action === 'reset_overrides') {
$db->execute(
'DELETE sm FROM student_modules sm JOIN students s ON s.id = sm.student_id WHERE s.class_id = ? AND sm.module_id = ?',
[$classId, $moduleId]
);
Response::ok();
}
Response::error('Unbekannte Aktion');
}
Response::error('Methode nicht erlaubt', 405);