= 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 je Bestellung als Freitext ablegen (Name/Verlag/Schule + Mail) — * KEIN Abgleich/Dedup, da sich Ansprechpartner ändern können. */ function lic_create_customer(Database $db, ?string $email, ?string $name): ?int { $email = $email ? trim($email) : null; $name = $name ? trim($name) : null; if (!$email && !$name) return null; $db->execute("INSERT INTO license_customers (email, name) VALUES (?,?)", [$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]); } /** App-Setting lesen (app_settings), robust mit Default. */ function lic_cfg(Database $db, string $key, string $default = ''): string { try { $r = $db->fetchOne("SELECT sval FROM app_settings WHERE skey = ?", [$key]); return ($r && $r['sval'] !== null && $r['sval'] !== '') ? (string)$r['sval'] : $default; } catch (\Throwable $e) { return $default; } } // === 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, ]); } // Mail-System-Konfiguration if (isset($_GET['mail_config'])) { Response::ok([ 'provider' => lic_cfg($db, 'mail_provider', 'server'), 'from_email' => lic_cfg($db, 'mail_from_email', 'support@geograsim.at'), 'from_name' => lic_cfg($db, 'mail_from_name', 'GeoGraSim'), 'public_url' => lic_cfg($db, 'mail_public_url', 'https://geograsim.at'), ]); } // 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), storniert ausgeschlossen. // Reihenfolge = id (= Reihenfolge, in der die Codes ausgegeben/reinkopiert wurden). $freeLicenses = $db->fetchAll( 'SELECT id, code, school_year FROM licenses WHERE teacher_id = ? AND student_id IS NULL AND canceled_at IS NULL ORDER BY id', [$teacherId] ); // Schüler:innen der Klasse OHNE gültige Lizenz (für die rote Cockpit-Warnung). $unlicensed = $db->fetchAll( "SELECT s.id, s.username, s.display_name FROM students s WHERE s.class_id = ? AND s.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM licenses l WHERE l.student_id = s.id AND l.canceled_at IS NULL AND (l.valid_until IS NULL OR l.valid_until >= CURDATE())) ORDER BY s.username", [$classId] ); Response::ok(['assigned' => $licenses, 'free' => $freeLicenses, 'unlicensed' => $unlicensed]); } // === 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); $ordered = (int)($body['count'] ?? 0); if ($ordered < 1 || $ordered > 5000) Response::error('Anzahl muss zwischen 1 und 5000 liegen'); // Gratis-Zugabe: +10 %, aufgerundet (14 → 16). Standard aktiv. $bonus = !array_key_exists('bonus', $body) || !empty($body['bonus']); $count = $bonus ? (int)ceil($ordered * 1.1) : $ordered; $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_create_customer($db, $cust['email'] ?? 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, qty_ordered, default_valid_from, default_valid_until, note, redeem_token, created_by) VALUES (?,?,?,?,?,?,?,?,?,?)", [$customerId, $orderNo, $type, $count, $ordered, $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', "bestellt $ordered, geliefert $made (Zugabe " . ($bonus ? 'ja' : 'nein') . "), 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, 'internal_no' => $batchId, 'redeem_token' => $token, 'valid_from' => $validFrom, 'valid_until' => $validUntil, 'ordered' => $ordered, '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'); $provider = lic_cfg($db, 'mail_provider', 'server'); if ($provider !== 'server') Response::error('Aktiver Mail-Treiber „' . $provider . '" ist noch nicht verfügbar. In den Mail-Einstellungen auf „server" stellen.'); $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, $provider, '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]); } // ═══════════════ Mail-System-Konfiguration ═══════════════ if ($action === 'set_mail_config') { $adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403); $prov = in_array($body['provider'] ?? '', ['server', 'aws'], true) ? $body['provider'] : 'server'; $femail = trim((string)($body['from_email'] ?? '')); $fname = trim((string)($body['from_name'] ?? '')); $purl = rtrim(trim((string)($body['public_url'] ?? '')), '/'); if ($femail !== '' && !filter_var($femail, FILTER_VALIDATE_EMAIL)) Response::error('Absender-Adresse ungültig'); $set = ['mail_provider' => $prov, 'mail_from_email' => $femail, 'mail_from_name' => $fname, 'mail_public_url' => $purl]; foreach ($set as $k => $v) { if ($v === '') continue; $db->execute("INSERT INTO app_settings (skey, sval, updated_by) VALUES (?,?,?) ON DUPLICATE KEY UPDATE sval = VALUES(sval), updated_by = VALUES(updated_by)", [$k, $v, $adminId]); } Response::ok(); } if ($action === 'test_mail') { $adminId = Session::adminId(); if (!$adminId) Response::error('Nur Admin', 403); $to = trim((string)($body['to_email'] ?? '')); if (!filter_var($to, FILTER_VALIDATE_EMAIL)) Response::error('Gültige Test-Adresse erforderlich'); $provider = lic_cfg($db, 'mail_provider', 'server'); if ($provider !== 'server') Response::error('Aktiver Treiber „' . $provider . '" ist noch nicht verfügbar. Auf „server" stellen.'); try { Mailer::sendWithAttachments($to, 'GeoGraSim — Test-Mail', Mailer::renderPlainBody("Test-Mail aus der Lizenz-Verwaltung.\nAbsender und Versand funktionieren."), 'Test-Mail — Versand funktioniert.'); Response::ok(['status' => 'sent']); } catch (\Throwable $e) { Response::error('Versand fehlgeschlagen: ' . $e->getMessage(), 500); } } Response::error('Unbekannte Aktion'); } Response::error('Methode nicht erlaubt', 405);