Files
geograsim/App/php/api/licenses.php
T
Adminator 50c72fa8d8 Lizenz-Vertrieb Feinschliff: Zugabe +10%, sichere Datei-Einloesung, Mail-Config
Umsetzung des Thomas-Feedbacks:
- Reihenfolge: freie Lizenzen beim Lehrer jetzt ORDER BY id (= Reihenfolge der
  reinkopierten Liste); stornierte ausgeblendet.
- Gratis-Zugabe: Checkbox (Default an) "+10 % aufgerundet" -> geliefert =
  ceil(bestellt*1.1) (14 -> 16). Batch speichert qty_ordered (verrechnet) und
  qty (geliefert); Live-Vorschau im Formular.
- Einloeselink/QR ENTFERNT (Sicherheitsrisiko: falscher eingeloggter Account).
  einloesen.html + pages/einloesen.php geloescht, Sammellink aus UI/Mail raus.
- CSV: Gueltig-bis + Metadaten nur in der KOPFZEILE, danach reine Code-Zeilen
  (per Copy-Paste einloesbar). Dateiname: <ExtBestellnr|Datum>_geograsim_<intNr>.
- Interne Bestellnummer = batch.id (laufend, nicht editierbar, storno-stabil).
- Lehrer-Einloesen: sichere .txt/.csv-Ablage (mehrstufig: Endung+MIME, Groessen-
  limit, nur Text lesen, Whitelist-Regex extrahiert nur Code-Tokens, Server
  validiert) — kein Upload, keine Ausfuehrung/Rendering.
- Mailsystem: zentrale Config in neuer app_settings (Provider server|aws,
  Absender-Name/-Adresse, oeffentliche URL), pflegbar in Admin + Test-Versand.
  Mailer liest Absender aus Config (SMTP-Zugang bleibt in .env). Mailtext nutzt
  kanonische geograsim.at (nicht v3/localhost).

Migrationen: -2 (qty_ordered), -3 (app_settings). Beide additiv, auf v3 angewandt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-26 11:15:53 +02:00

550 lines
30 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: Lizenzverwaltung
* GET /api/licenses?class_id=X → Lizenzen der Klasse (Lehrer)
* GET /api/licenses?admin=1 → Alle Lizenzen (Super-Admin)
* POST /api/licenses {action} → Lizenz einlösen/zuweisen/generieren
*/
$method = $_SERVER['REQUEST_METHOD'];
$db = Database::get();
// ─────────────────────────────────────────────────────────────────────
// Vertriebs-Helfer (Chargen-Vergabe, Storno, Verlaengerung, Mail, Schutz)
// ─────────────────────────────────────────────────────────────────────
const LIC_TYPES = ['marktplatz', 'direktvertrieb', 'privat'];
/** Neuer Lizenzcode: 5×4 aus 32er-Alphabet (ohne I,O,0,1) = ~100 Bit Entropie. */
function lic_new_code(): string {
$a = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $n = strlen($a);
$g = [];
for ($i = 0; $i < 5; $i++) {
$s = '';
for ($k = 0; $k < 4; $k++) $s .= $a[random_int(0, $n - 1)];
$g[] = $s;
}
return implode('-', $g); // XXXX-XXXX-XXXX-XXXX-XXXX (24 Zeichen)
}
/** Schuljahr-String "26/27" aus einem Datum (Schuljahr beginnt im August). */
function lic_school_year_from(?string $date): string {
$ts = $date ? strtotime($date) : time();
$y = (int)date('Y', $ts); $m = (int)date('n', $ts);
$start = ($m >= 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]);
}
/** 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]
);
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);
$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_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, 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);