Files
geograsim/App/php/api/modules.php
T
Adminator 55a83d5daf Tourismustal: iPad-Feedback Runde 2 (5 Punkte)
- Bau-Ablehnung erklaert jetzt immer auch die Bauregeln des Gebaeudes
  (Toast: Grund + Beschreibung, 7 s).
- Lehrer-Schalter fuer den Vorlese-Modus: class_modules.audio_info
  (Migration, Default AN) + modules-API set_audio_info + Toggle auf der
  Modul-Karte im Cockpit (Vorlesen an/aus) + Wrapper injiziert
  TT_FEATURES.audioInfo + Sim blendet den Umschalter aus und erzwingt
  Lese-Modus, wenn gesperrt.
- Bus zeigt bei Rueckfahrt sein Heck: Heckscheibe + rote Ruecklichter,
  Tuer/Spiegel richtungsabhaengig.
- Bikepark-Flowtrails: 3 Stufen hangAUFwaerts (gleiche Richtung wie
  Lift-Pisten).
- Info-Button an jedem Bauwerk im Baumenue: erklaert Wirkung, Kosten,
  Lage-Regeln und Lehrplan-Wissen VOR dem Bau.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:07:03 +02:00

324 lines
14 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();
// 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',
];
}
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 s.id FROM students s JOIN student_sessions ss ON ss.class_id = s.class_id AND ss.display_name = s.display_name WHERE ss.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'];
}
// 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'] ?? 'locked');
// '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,
];
}
// 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']] ?? 'locked';
}
$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']] ?? 'locked',
'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']] ?? 'locked',
];
}
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);