= 8) ? $y : $y - 1; return ($start % 100) . '/' . (($start % 100) + 1); } /** Sticky Default fuer Gueltig-bis: letzte verwendete, sonst 1. Okt End-Jahr Schuljahr. */ function lic_default_valid_until(Database $db): string { $last = $db->fetchOne("SELECT default_valid_until FROM license_batches WHERE default_valid_until IS NOT NULL ORDER BY id DESC LIMIT 1"); if ($last && !empty($last['default_valid_until'])) return $last['default_valid_until']; $y = (int)date('Y'); $m = (int)date('n'); $start = ($m >= 8) ? $y : $y - 1; return ($start + 1) . '-10-01'; } /** Kunde finden (nach Kundennummer, dann Email) oder anlegen. Ergaenzt fehlende Felder. */ function lic_find_or_create_customer(Database $db, ?string $email, ?string $customerNo, ?string $name): ?int { $email = $email ? trim($email) : null; $customerNo = $customerNo ? trim($customerNo) : null; $name = $name ? trim($name) : null; if (!$email && !$customerNo && !$name) return null; $found = null; if ($customerNo) $found = $db->fetchOne("SELECT id FROM license_customers WHERE customer_no = ? LIMIT 1", [$customerNo]); if (!$found && $email) $found = $db->fetchOne("SELECT id FROM license_customers WHERE email = ? LIMIT 1", [$email]); if ($found) { $db->execute("UPDATE license_customers SET email = COALESCE(email, ?), customer_no = COALESCE(customer_no, ?), name = COALESCE(name, ?) WHERE id = ?", [$email, $customerNo, $name, $found['id']]); return (int)$found['id']; } $db->execute("INSERT INTO license_customers (customer_no, email, name) VALUES (?,?,?)", [$customerNo, $email, $name]); return (int)$db->lastInsertId(); } /** Audit-Eintrag (erzeugt/verlaengert/storniert/gemailt/eingeloest). */ function lic_event(Database $db, ?int $batchId, ?int $licenseId, string $event, ?string $detail, ?int $adminId): void { $db->execute("INSERT INTO license_events (batch_id, license_id, event, detail, admin_id) VALUES (?,?,?,?,?)", [$batchId, $licenseId, $event, $detail, $adminId]); } /** Brute-Force-Bremse beim Einloesen: 10 Fehlversuche/10min pro IP, 20 pro Lehrer. */ function lic_throttle_check(Database $db, ?int $teacherId): void { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; if ($ip !== '') { $r = $db->fetchOne("SELECT COUNT(*) c FROM license_redeem_attempts WHERE ok=0 AND ip=INET6_ATON(?) AND created_at > (NOW() - INTERVAL 10 MINUTE)", [$ip]); if ($r && (int)$r['c'] >= 10) Response::error('Zu viele Fehlversuche. Bitte in ein paar Minuten erneut.', 429); } if ($teacherId) { $r = $db->fetchOne("SELECT COUNT(*) c FROM license_redeem_attempts WHERE ok=0 AND teacher_id=? AND created_at > (NOW() - INTERVAL 10 MINUTE)", [$teacherId]); if ($r && (int)$r['c'] >= 20) Response::error('Zu viele Fehlversuche. Bitte spaeter erneut.', 429); } } /** Einloese-Versuch protokollieren (fuer Schutz + Audit). */ function lic_attempt(Database $db, ?int $teacherId, string $code, bool $ok): void { $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $db->execute("INSERT INTO license_redeem_attempts (ip, teacher_id, code_tried, ok) VALUES (INET6_ATON(?), ?, ?, ?)", [$ip !== '' ? $ip : null, $teacherId, substr($code, 0, 32), $ok ? 1 : 0]); } // === GET === if ($method === 'GET') { // Super-Admin: alle Lizenzen if (isset($_GET['admin']) && $_GET['admin'] === '1') { $adminId = Session::adminId(); if (!$adminId) { Response::error('Nicht autorisiert', 401); } // Vorbelegung fuer das Ausgabe-Formular (Sticky Gueltig-bis + Typen) if (isset($_GET['sales_default'])) { Response::ok([ 'valid_from' => date('Y-m-d'), 'valid_until' => lic_default_valid_until($db), 'types' => LIC_TYPES, ]); } // Kunden-Autocomplete if (isset($_GET['customers'])) { $like = '%' . trim($_GET['customers']) . '%'; $rows = $db->fetchAll( "SELECT id, customer_no, email, name FROM license_customers WHERE email LIKE ? OR name LIKE ? OR customer_no LIKE ? ORDER BY name, email LIMIT 10", [$like, $like, $like]); Response::ok(['customers' => $rows]); } // Chargen-Uebersicht (mit Aggregaten + Filter) if (($_GET['view'] ?? '') === 'batches') { $where = '1=1'; $p = []; if (!empty($_GET['type'])) { $where .= ' AND b.sales_type = ?'; $p[] = $_GET['type']; } if (!empty($_GET['order_no'])) { $where .= ' AND b.order_no LIKE ?'; $p[] = '%' . $_GET['order_no'] . '%'; } if (!empty($_GET['customer'])) { $q = '%' . $_GET['customer'] . '%'; $where .= ' AND (c.email LIKE ? OR c.name LIKE ? OR c.customer_no LIKE ?)'; $p[] = $q; $p[] = $q; $p[] = $q; } $batches = $db->fetchAll( "SELECT b.id, b.order_no, b.sales_type, b.qty, b.default_valid_from, b.default_valid_until, b.redeem_token, b.created_at, b.canceled_at, b.note, c.email AS customer_email, c.name AS customer_name, c.customer_no, COUNT(l.id) AS n_total, SUM(l.canceled_at IS NOT NULL) AS n_canceled, SUM(l.redeemed_at IS NOT NULL AND l.canceled_at IS NULL) AS n_redeemed, SUM(l.canceled_at IS NULL AND l.valid_until IS NOT NULL AND l.valid_until < CURDATE()) AS n_expired FROM license_batches b LEFT JOIN license_customers c ON c.id = b.customer_id LEFT JOIN licenses l ON l.batch_id = b.id WHERE $where GROUP BY b.id ORDER BY b.id DESC LIMIT 300", $p); Response::ok(['batches' => $batches]); } // Detail einer Charge: Codes + Verlauf + Mails if (($_GET['view'] ?? '') === 'batch_detail') { $bid = (int)($_GET['batch_id'] ?? 0); $batch = $db->fetchOne( "SELECT b.*, c.email AS customer_email, c.name AS customer_name, c.customer_no FROM license_batches b LEFT JOIN license_customers c ON c.id = b.customer_id WHERE b.id = ?", [$bid]); if (!$batch) Response::error('Charge nicht gefunden', 404); $codes = $db->fetchAll( "SELECT l.id, l.code, l.valid_from, l.valid_until, l.issued_at, l.redeemed_at, l.student_id, l.teacher_id, l.canceled_at, l.canceled_reason, t.display_name AS teacher_name, s.username AS student_name FROM licenses l LEFT JOIN teachers t ON t.id = l.teacher_id LEFT JOIN students s ON s.id = l.student_id WHERE l.batch_id = ? ORDER BY l.id", [$bid]); $events = $db->fetchAll("SELECT event, detail, admin_id, created_at FROM license_events WHERE batch_id = ? ORDER BY id DESC LIMIT 100", [$bid]); $mails = $db->fetchAll("SELECT id, to_email, subject, status, error, created_at, sent_at FROM license_mails WHERE batch_id = ? ORDER BY id DESC", [$bid]); Response::ok(['batch' => $batch, 'codes' => $codes, 'events' => $events, 'mails' => $mails]); } $year = $_GET['year'] ?? null; $status = $_GET['status'] ?? null; // 'free', 'used' $where = '1=1'; $params = []; if ($year) { $where .= ' AND l.school_year = ?'; $params[] = $year; } if ($status === 'free') { $where .= ' AND l.student_id IS NULL'; } if ($status === 'used') { $where .= ' AND l.student_id IS NOT NULL'; } $total = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE $where", $params); $free = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE student_id IS NULL" . ($year ? " AND school_year = ?" : ""), $year ? [$year] : []); $used = $db->fetchOne("SELECT COUNT(*) as c FROM licenses l WHERE student_id IS NOT NULL" . ($year ? " AND school_year = ?" : ""), $year ? [$year] : []); $limit = (int)($_GET['limit'] ?? 100); $offset = (int)($_GET['offset'] ?? 0); $licenses = $db->fetchAll( "SELECT l.id, l.code, l.school_year, l.student_id, l.teacher_id, l.redeemed_at, l.created_at, s.username as student_name, t.display_name as teacher_name FROM licenses l LEFT JOIN students s ON s.id = l.student_id LEFT JOIN teachers t ON t.id = l.teacher_id WHERE $where ORDER BY l.id DESC LIMIT $limit OFFSET $offset", $params ); Response::ok([ 'total' => (int)$total['c'], 'free' => (int)$free['c'], 'used' => (int)$used['c'], 'licenses' => $licenses ]); } // Lehrer: Lizenzen einer Klasse $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); $licenses = $db->fetchAll( 'SELECT l.id, l.code, l.school_year, l.student_id, l.redeemed_at, s.username as student_name, s.display_name as student_display FROM licenses l JOIN students s ON s.id = l.student_id WHERE s.class_id = ? ORDER BY s.username', [$classId] ); // Auch freie Lizenzen des Lehrers (noch nicht zugewiesen) $freeLicenses = $db->fetchAll( 'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL ORDER BY code', [$teacherId] ); Response::ok(['assigned' => $licenses, 'free' => $freeLicenses]); } // === POST === if ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true); $action = $body['action'] ?? ''; // Lizenz einlösen (Lehrer gibt Code ein → wird ihm zugeordnet) if ($action === 'redeem') { $teacherId = Session::requireTeacher(); $code = strtoupper(trim($body['code'] ?? '')); if (!$code) Response::error('Lizenzcode erforderlich'); lic_throttle_check($db, $teacherId); $license = $db->fetchOne('SELECT id, batch_id, student_id, teacher_id, canceled_at FROM licenses WHERE code = ?', [$code]); if (!$license) { lic_attempt($db, $teacherId, $code, false); Response::error('Lizenzcode ungültig'); } if ($license['canceled_at']) { lic_attempt($db, $teacherId, $code, false); Response::error('Dieser Lizenzcode wurde storniert'); } if ($license['student_id']) { lic_attempt($db, $teacherId, $code, false); Response::error('Diese Lizenz ist bereits zugewiesen'); } if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { lic_attempt($db, $teacherId, $code, false); Response::error('Diese Lizenz gehört einer anderen Lehrperson'); } $db->execute('UPDATE licenses SET teacher_id = ?, redeemed_at = NOW() WHERE id = ?', [$teacherId, $license['id']]); lic_attempt($db, $teacherId, $code, true); lic_event($db, $license['batch_id'] ? (int)$license['batch_id'] : null, (int)$license['id'], 'redeemed', 'durch Lehrer #' . $teacherId, null); Response::ok(['licenseId' => (int)$license['id']]); } // Mehrere Lizenzen auf einmal einlösen if ($action === 'redeem_batch') { $teacherId = Session::requireTeacher(); $codes = $body['codes'] ?? []; if (!is_array($codes) || empty($codes)) Response::error('Lizenzcodes erforderlich'); lic_throttle_check($db, $teacherId); $redeemed = 0; $errors = []; foreach ($codes as $code) { $code = strtoupper(trim($code)); if (!$code) continue; $license = $db->fetchOne('SELECT id, student_id, teacher_id, canceled_at FROM licenses WHERE code = ?', [$code]); if (!$license) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: ungültig"; continue; } if ($license['canceled_at']) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: storniert"; continue; } if ($license['student_id']) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: bereits zugewiesen"; continue; } if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) { lic_attempt($db, $teacherId, $code, false); $errors[] = "$code: gehört anderer Lehrperson"; continue; } $db->execute('UPDATE licenses SET teacher_id = ?, redeemed_at = NOW() WHERE id = ?', [$teacherId, $license['id']]); lic_attempt($db, $teacherId, $code, true); $redeemed++; } Response::ok(['redeemed' => $redeemed, 'errors' => $errors]); } // Lizenz einem Schüler zuweisen if ($action === 'assign') { $teacherId = Session::requireTeacher(); $licenseId = (int)($body['licenseId'] ?? 0); $studentId = (int)($body['studentId'] ?? 0); if (!$licenseId || !$studentId) Response::error('licenseId und studentId erforderlich'); $license = $db->fetchOne('SELECT id, teacher_id FROM licenses WHERE id = ? AND student_id IS NULL', [$licenseId]); if (!$license) Response::error('Lizenz nicht gefunden oder bereits zugewiesen'); if ($license['teacher_id'] && $license['teacher_id'] != $teacherId) Response::error('Lizenz gehört anderer Lehrperson'); // Prüfen dass Schüler dem Lehrer gehört $student = $db->fetchOne( 'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?', [$studentId, $teacherId] ); if (!$student) Response::error('Schüler*in nicht gefunden'); $db->execute('UPDATE licenses SET student_id = ?, teacher_id = ? WHERE id = ?', [$studentId, $teacherId, $licenseId]); Response::ok(); } // Auto-Assign: nächste freie Lizenz einem Schüler zuweisen if ($action === 'auto_assign') { $teacherId = Session::requireTeacher(); $studentId = (int)($body['studentId'] ?? 0); if (!$studentId) Response::error('studentId erforderlich'); $student = $db->fetchOne( 'SELECT s.id FROM students s JOIN classes c ON c.id = s.class_id WHERE s.id = ? AND c.teacher_id = ?', [$studentId, $teacherId] ); if (!$student) Response::error('Schüler*in nicht gefunden'); // Nächste freie Lizenz des Lehrers $license = $db->fetchOne( 'SELECT id FROM licenses WHERE teacher_id = ? AND student_id IS NULL ORDER BY id LIMIT 1', [$teacherId] ); if (!$license) Response::error('Keine freien Lizenzen verfügbar'); $db->execute('UPDATE licenses SET student_id = ? WHERE id = ?', [$studentId, $license['id']]); Response::ok(['licenseId' => (int)$license['id']]); } // Super-Admin: neue Lizenzen generieren (NUR echter Admin) if ($action === 'generate') { if (!Session::adminId()) Response::error('Nur Admin', 403); $count = min(1000, max(1, (int)($body['count'] ?? 100))); $year = (int)($body['year'] ?? date('Y')); $schoolYear = ($year % 100) . '/' . (($year % 100) + 1); $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $generated = 0; $tries = 0; $maxTries = $count * 10 + 100; while ($generated < $count && $tries < $maxTries) { $tries++; $code = ''; for ($j = 0; $j < 3; $j++) { if ($j > 0) $code .= '-'; for ($k = 0; $k < 4; $k++) $code .= $chars[random_int(0, strlen($chars) - 1)]; } $code .= '-' . $year; try { $db->execute('INSERT INTO licenses (code, school_year) VALUES (?, ?)', [$code, $schoolYear]); $generated++; } catch (\Exception $e) { // Kollision (unwahrscheinlich) → nächster Versuch; $maxTries schützt vor Endlosschleife } } Response::ok(['generated' => $generated]); } // ═══════════════ Vertrieb: Charge ausgeben ═══════════════ if ($action === 'issue_batch') { $adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403); $count = (int)($body['count'] ?? 0); if ($count < 1 || $count > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen'); $type = in_array($body['sales_type'] ?? '', LIC_TYPES, true) ? $body['sales_type'] : 'privat'; $orderNo = trim((string)($body['order_no'] ?? '')) ?: null; $note = trim((string)($body['note'] ?? '')) ?: null; $validFrom = !empty($body['valid_from']) ? $body['valid_from'] : date('Y-m-d'); $validUntil = !empty($body['valid_until']) ? $body['valid_until'] : lic_default_valid_until($db); if (!strtotime($validFrom) || !strtotime($validUntil)) Response::error('Ungültiges Datum'); if (strtotime($validUntil) < strtotime($validFrom)) Response::error('„Gültig bis" liegt vor „Gültig von"'); $cust = is_array($body['customer'] ?? null) ? $body['customer'] : []; $customerId = lic_find_or_create_customer($db, $cust['email'] ?? null, $cust['customer_no'] ?? null, $cust['name'] ?? null); $schoolYear = lic_school_year_from($validFrom); $token = bin2hex(random_bytes(20)); $db->begin(); try { $db->execute( "INSERT INTO license_batches (customer_id, order_no, sales_type, qty, default_valid_from, default_valid_until, note, redeem_token, created_by) VALUES (?,?,?,?,?,?,?,?,?)", [$customerId, $orderNo, $type, $count, $validFrom, $validUntil, $note, $token, $adminId]); $batchId = (int)$db->lastInsertId(); $codes = []; $made = 0; $tries = 0; $maxTries = $count * 8 + 50; while ($made < $count && $tries < $maxTries) { $tries++; $code = lic_new_code(); try { $db->execute( "INSERT INTO licenses (code, school_year, batch_id, issued_at, valid_from, valid_until) VALUES (?,?,?,NOW(),?,?)", [$code, $schoolYear, $batchId, $validFrom, $validUntil]); $codes[] = $code; $made++; } catch (\Throwable $e) { /* Code-Kollision (extrem selten) → neuer Versuch */ } } if ($made < $count) throw new \RuntimeException("Nur $made von $count Codes erzeugt"); lic_event($db, $batchId, null, 'issued', "$count Codes, gültig $validFrom…$validUntil, Typ $type" . ($orderNo ? ", Best.-Nr. $orderNo" : ''), $adminId); $db->commit(); } catch (\Throwable $e) { $db->rollBack(); Response::error('Fehler beim Erzeugen: ' . $e->getMessage(), 500); } Response::ok([ 'batch_id' => $batchId, 'redeem_token' => $token, 'valid_from' => $validFrom, 'valid_until' => $validUntil, 'count' => $made, 'codes' => $codes, ]); } // ═══════════════ Storno ═══════════════ if ($action === 'cancel_batch') { $adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403); $bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich'); $reason = trim((string)($body['reason'] ?? '')) ?: null; $n = $db->execute("UPDATE licenses SET canceled_at = NOW(), canceled_reason = ? WHERE batch_id = ? AND canceled_at IS NULL", [$reason, $bid]); $db->execute("UPDATE license_batches SET canceled_at = NOW(), canceled_reason = ? WHERE id = ?", [$reason, $bid]); lic_event($db, $bid, null, 'canceled', "Charge storniert ($n Codes)" . ($reason ? ": $reason" : ''), $adminId); Response::ok(['canceled' => $n]); } if ($action === 'cancel_license') { $adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403); $lid = (int)($body['license_id'] ?? 0); if (!$lid) Response::error('license_id erforderlich'); $reason = trim((string)($body['reason'] ?? '')) ?: null; $lic = $db->fetchOne("SELECT batch_id FROM licenses WHERE id = ?", [$lid]); if (!$lic) Response::error('Code nicht gefunden', 404); $db->execute("UPDATE licenses SET canceled_at = NOW(), canceled_reason = ? WHERE id = ? AND canceled_at IS NULL", [$reason, $lid]); lic_event($db, $lic['batch_id'] ? (int)$lic['batch_id'] : null, $lid, 'canceled', 'Einzel-Storno' . ($reason ? ": $reason" : ''), $adminId); Response::ok(); } // ═══════════════ Verlängern ═══════════════ if ($action === 'extend_license') { $adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403); $lid = (int)($body['license_id'] ?? 0); $vu = $body['valid_until'] ?? ''; if (!$lid || !strtotime($vu)) Response::error('license_id + valid_until erforderlich'); $lic = $db->fetchOne("SELECT batch_id, valid_until FROM licenses WHERE id = ?", [$lid]); if (!$lic) Response::error('Code nicht gefunden', 404); $db->execute("UPDATE licenses SET valid_until = ? WHERE id = ?", [$vu, $lid]); lic_event($db, $lic['batch_id'] ? (int)$lic['batch_id'] : null, $lid, 'extended', 'valid_until ' . ($lic['valid_until'] ?? '—') . " → $vu", $adminId); Response::ok(); } if ($action === 'extend_batch') { $adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403); $bid = (int)($body['batch_id'] ?? 0); $vu = $body['valid_until'] ?? ''; if (!$bid || !strtotime($vu)) Response::error('batch_id + valid_until erforderlich'); $n = $db->execute("UPDATE licenses SET valid_until = ? WHERE batch_id = ? AND canceled_at IS NULL", [$vu, $bid]); $db->execute("UPDATE license_batches SET default_valid_until = ? WHERE id = ?", [$vu, $bid]); lic_event($db, $bid, null, 'extended', "Charge verlängert auf $vu ($n Codes)", $adminId); Response::ok(['updated' => $n]); } // ═══════════════ Mailversand (Server-Treiber, archiviert) ═══════════════ if ($action === 'send_batch_mail') { $adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403); $bid = (int)($body['batch_id'] ?? 0); if (!$bid) Response::error('batch_id erforderlich'); $to = trim((string)($body['to_email'] ?? '')); $subject = trim((string)($body['subject'] ?? '')) ?: 'Ihre GeoGraSim-Lizenzcodes'; $textBody = (string)($body['body_text'] ?? ''); if (!filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error('Gültige Empfänger-Mailadresse erforderlich'); if (trim($textBody) === '') Response::error('Mailtext ist leer'); $codeRows = $db->fetchAll("SELECT code FROM licenses WHERE batch_id = ? AND canceled_at IS NULL ORDER BY id", [$bid]); $codeList = array_map(fn($r) => $r['code'], $codeRows); $html = Mailer::renderPlainBody($textBody); $attach = []; if (count($codeList) > 40) { $attach[] = ['content' => implode("\r\n", $codeList) . "\r\n", 'name' => 'lizenzcodes.txt', 'type' => 'text/plain']; } $db->execute( "INSERT INTO license_mails (batch_id, to_email, subject, body_text, body_html, provider, status, created_by) VALUES (?,?,?,?,?,?,?,?)", [$bid, $to, $subject, $textBody, $html, 'server', 'queued', $adminId]); $mailId = (int)$db->lastInsertId(); try { Mailer::sendWithAttachments($to, $subject, $html, $textBody, $attach); $db->execute("UPDATE license_mails SET status='sent', sent_at=NOW() WHERE id=?", [$mailId]); lic_event($db, $bid, null, 'mailed', "an $to", $adminId); Response::ok(['mail_id' => $mailId, 'status' => 'sent']); } catch (\Throwable $e) { $db->execute("UPDATE license_mails SET status='failed', error=? WHERE id=?", [substr($e->getMessage(), 0, 240), $mailId]); Response::error('Versand fehlgeschlagen: ' . $e->getMessage(), 500); } } // ═══════════════ Sammel-Einlösen einer Charge (ein Link → alle Codes) ═══════════════ if ($action === 'redeem_charge') { $teacherId = Session::requireTeacher(); $token = trim((string)($body['token'] ?? '')); if ($token === '') Response::error('Token erforderlich'); lic_throttle_check($db, $teacherId); $batch = $db->fetchOne("SELECT id FROM license_batches WHERE redeem_token = ? AND canceled_at IS NULL", [$token]); if (!$batch) { lic_attempt($db, $teacherId, 'charge:' . substr($token, 0, 24), false); Response::error('Einlöse-Link ungültig oder storniert'); } $n = $db->execute( "UPDATE licenses SET teacher_id = ?, redeemed_at = COALESCE(redeemed_at, NOW()) WHERE batch_id = ? AND canceled_at IS NULL AND (teacher_id IS NULL OR teacher_id = ?)", [$teacherId, $batch['id'], $teacherId]); lic_attempt($db, $teacherId, 'charge:' . substr($token, 0, 24), true); lic_event($db, (int)$batch['id'], null, 'redeemed', "Charge-Sammellink durch Lehrer #$teacherId ($n Codes)", null); Response::ok(['redeemed' => $n]); } Response::error('Unbekannte Aktion'); } Response::error('Methode nicht erlaubt', 405);