Files
geograsim/App/php/api/live.php
T
Adminator 33fb423967 Atlas: Live-View-Feature + Submit-Bug-Fixes + Admin-Styleguide
- 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>
2026-05-07 01:20:53 +02:00

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);