Files
geograsim/App/php/api/modules.php
T
Adminator fb44ec7afe student_id-FK-Migration Stufe 1+2: Name-Joins -> student_sessions.student_id
- student_sessions: neue Spalte student_id (+Index), backfilled aus (class_id,display_name)
- Session::createStudent setzt student_id (per Lookup, falls nicht uebergeben)
- alle Joins students<->student_sessions von display_name auf student_id umgestellt:
  Benchmark.php, live.php (5x), results.php (4x + runs), modules.php, profile.php, progress.php (answers+runs)
- verlustfrei verifiziert (alt-Join == neu-Join, class 99/102)

Robuster gegen Umbenennung/Doppelnamen, indexierbare Joins.

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

329 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();
// 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',
];
}
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'];
}
// 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,
];
}
// 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);