33fb423967
- Neuer API-Endpoint /api/live (heartbeat / spectator) + DB-Tabelle live_sessions + class_modules.paused-Spalte - Plattform-Live-Client (assets/js/live-client.js): Heartbeat, Pause-Overlay, Spectator-Mode bei ?view=teacher - ggs_inject_live() in _scripts.php als Helper, in alle 11 Sim-Wrapper integriert - Lehrer-Cockpit: tabellarische Klassen-Live-Ansicht pro Sim, Pause-Toggle, blinkender Tab-Indikator wenn aktive Sessions - Submit-Bug erschlagen: progress.php hat jetzt submit_assessment- und reflection-Action; saves.php akzeptiert beide Key-Konventionen; alle Direkt-Pfad-API-Files mit require_once-Bootstrap - Avatar-Default: zufaelliger DALL-E-Avatar fuer neue Schueler:innen - Admin-Styleguide-Seite mit Bildstil, Farb-Tokens, Layout, iPad-Pattern - Klima 2D + 3D: GGS_LIVE_STATE-Hook, Klima 2D zusaetzlich skipResume fuer Klassenaufgaben-Reset - Test-Lehrer Jakob + 3 Schueler:innen lokal + auf Live angelegt - Rundbriefe an alle Sim-Instanzen + Submit-Briefe an sonnensystem, busfahrt, energiemanager + Glossar-Anfrage zu Grundriess - Status-Uebergabe in _inbox/zentrale/_status.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
138 lines
6.3 KiB
PHP
138 lines
6.3 KiB
PHP
<?php
|
|
/**
|
|
* API: Live-Sessions (Lehrer schaut Schüler:innen live beim Spielen zu)
|
|
*
|
|
* POST /api/live {action:'heartbeat', module_id, state} → Schüler-Sim, alle 3-5s
|
|
* GET /api/live?class_id=X → Lehrer: alle aktiven Sessions der Klasse
|
|
* GET /api/live?student_id=X → Lehrer: state eines Schülers für Spectator-View
|
|
*
|
|
* "Aktiv" = letzter Heartbeat liegt höchstens 30 s zurück.
|
|
*
|
|
* Bootstrap: idempotent, falls die Datei direkt (nicht über index.php) angesprochen wird.
|
|
*/
|
|
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();
|
|
|
|
const LIVE_ACTIVE_WINDOW_SEC = 30;
|
|
|
|
if ($method === 'POST') {
|
|
$sessionId = Session::requireStudent();
|
|
$body = json_decode(file_get_contents('php://input'), true);
|
|
$action = $body['action'] ?? 'heartbeat';
|
|
|
|
if ($action === 'heartbeat') {
|
|
$moduleId = (string)($body['module_id'] ?? $body['moduleId'] ?? '');
|
|
if ($moduleId === '' || strlen($moduleId) > 32) Response::error('module_id erforderlich');
|
|
$state = $body['state'] ?? null;
|
|
$stateJson = is_string($state) ? $state : json_encode($state, JSON_UNESCAPED_UNICODE);
|
|
if ($stateJson !== null && strlen($stateJson) > 50000) {
|
|
// Überlange States kappen (Sicherheitsnetz; State sollte schlank gehalten werden)
|
|
$stateJson = substr($stateJson, 0, 50000);
|
|
}
|
|
|
|
$session = $db->fetchOne('SELECT class_id, display_name FROM student_sessions WHERE id = ?', [$sessionId]);
|
|
if (!$session || !$session['class_id']) Response::ok(['ok' => false, 'reason' => 'no-class']);
|
|
$student = $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]);
|
|
if (!$student) Response::ok(['ok' => false, 'reason' => 'no-student']);
|
|
|
|
$studentId = (int)$student['id'];
|
|
$classId = (int)$session['class_id'];
|
|
|
|
// started_at frisch setzen, wenn (a) anderes Modul oder (b) länger als 30s
|
|
// kein Heartbeat kam (Sim wurde geschlossen und neu gestartet).
|
|
$db->execute(
|
|
'INSERT INTO live_sessions (student_id, class_id, module_id, state_json, started_at, last_seen)
|
|
VALUES (?, ?, ?, ?, NOW(), NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
class_id = VALUES(class_id),
|
|
module_id = VALUES(module_id),
|
|
state_json = VALUES(state_json),
|
|
started_at = IF(module_id <> VALUES(module_id) OR last_seen < NOW() - INTERVAL 30 SECOND, NOW(), started_at),
|
|
last_seen = NOW()',
|
|
[$studentId, $classId, $moduleId, $stateJson]
|
|
);
|
|
Response::ok(['ok' => true]);
|
|
}
|
|
|
|
if ($action === 'end') {
|
|
$session = $db->fetchOne('SELECT display_name, class_id FROM student_sessions WHERE id = ?', [$sessionId]);
|
|
$student = $session ? $db->fetchOne('SELECT id FROM students WHERE class_id = ? AND display_name = ?', [$session['class_id'], $session['display_name']]) : null;
|
|
if ($student) $db->execute('DELETE FROM live_sessions WHERE student_id = ?', [(int)$student['id']]);
|
|
Response::ok();
|
|
}
|
|
|
|
Response::error('Unbekannte Aktion');
|
|
}
|
|
|
|
if ($method === 'GET') {
|
|
$teacherId = Session::requireTeacher();
|
|
|
|
// Spectator: state eines Schülers
|
|
if (isset($_GET['student_id'])) {
|
|
$studentId = (int)$_GET['student_id'];
|
|
$row = $db->fetchOne(
|
|
'SELECT ls.*, s.display_name, s.username, s.emoji_avatar, c.id AS owner_class_id
|
|
FROM live_sessions ls
|
|
JOIN students s ON s.id = ls.student_id
|
|
JOIN classes c ON c.id = ls.class_id
|
|
WHERE ls.student_id = ? AND c.teacher_id = ?',
|
|
[$studentId, $teacherId]
|
|
);
|
|
if (!$row) Response::ok(['active' => false]);
|
|
$stale = (strtotime($row['last_seen']) < time() - LIVE_ACTIVE_WINDOW_SEC);
|
|
Response::ok([
|
|
'active' => !$stale,
|
|
'student_id' => (int)$row['student_id'],
|
|
'username' => $row['username'],
|
|
'displayName' => $row['display_name'] ?: $row['username'],
|
|
'emoji' => $row['emoji_avatar'] ?: '🧑🎓',
|
|
'module_id' => $row['module_id'],
|
|
'state' => $row['state_json'] ? json_decode($row['state_json'], true) : null,
|
|
'started_at' => $row['started_at'],
|
|
'last_seen' => $row['last_seen'],
|
|
]);
|
|
}
|
|
|
|
// Klassen-Liste: alle aktiven Sessions
|
|
$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);
|
|
|
|
$rows = $db->fetchAll(
|
|
'SELECT ls.student_id, ls.module_id, ls.state_json, ls.started_at, ls.last_seen,
|
|
s.username, s.display_name, s.emoji_avatar,
|
|
mi.title AS module_name, mi.icon AS module_icon, mi.play_url AS play_url
|
|
FROM live_sessions ls
|
|
JOIN students s ON s.id = ls.student_id
|
|
LEFT JOIN module_info mi ON mi.module_id = ls.module_id
|
|
WHERE ls.class_id = ?
|
|
AND ls.last_seen > NOW() - INTERVAL ? SECOND
|
|
ORDER BY ls.last_seen DESC',
|
|
[$classId, LIVE_ACTIVE_WINDOW_SEC]
|
|
);
|
|
$list = array_map(function($r) {
|
|
return [
|
|
'student_id' => (int)$r['student_id'],
|
|
'username' => $r['username'],
|
|
'displayName' => $r['display_name'] ?: $r['username'],
|
|
'emoji' => $r['emoji_avatar'] ?: '🧑🎓',
|
|
'module_id' => $r['module_id'],
|
|
'moduleName' => $r['module_name'] ?: $r['module_id'],
|
|
'moduleIcon' => $r['module_icon'] ?: '📚',
|
|
'playUrl' => $r['play_url'] ?: $r['module_id'],
|
|
'state' => $r['state_json'] ? json_decode($r['state_json'], true) : null,
|
|
'started_at' => $r['started_at'],
|
|
'last_seen' => $r['last_seen'],
|
|
];
|
|
}, $rows);
|
|
Response::ok($list);
|
|
}
|
|
|
|
Response::error('Methode nicht erlaubt', 405);
|